1/** js sequence diagrams
2 *  https://bramp.github.io/js-sequence-diagrams/
3 *  (c) 2012-2017 Andrew Brampton (bramp.net)
4 *  Simplified BSD license.
5 */
6/*global Diagram, _ */
7
8// Following the CSS convention
9// Margin is the gap outside the box
10// Padding is the gap inside the box
11// Each object has x/y/width/height properties
12// The x/y should be top left corner
13// width/height is with both margin and padding
14
15// TODO
16// Image width is wrong, when there is a note in the right hand col
17// Title box could look better
18// Note box could look better
19
20var DIAGRAM_MARGIN = 10;
21
22var ACTOR_MARGIN   = 10; // Margin around a actor
23var ACTOR_PADDING  = 10; // Padding inside a actor
24
25var SIGNAL_MARGIN  = 5; // Margin around a signal
26var SIGNAL_PADDING = 5; // Padding inside a signal
27
28var NOTE_MARGIN   = 10; // Margin around a note
29var NOTE_PADDING  = 5; // Padding inside a note
30var NOTE_OVERLAP  = 15; // Overlap when using a "note over A,B"
31
32var TITLE_MARGIN   = 0;
33var TITLE_PADDING  = 5;
34
35var SELF_SIGNAL_WIDTH = 20; // How far out a self signal goes
36
37var PLACEMENT = Diagram.PLACEMENT;
38var LINETYPE  = Diagram.LINETYPE;
39var ARROWTYPE = Diagram.ARROWTYPE;
40
41var ALIGN_LEFT   = 0;
42var ALIGN_CENTER = 1;
43
44function AssertException(message) { this.message = message; }
45AssertException.prototype.toString = function() {
46  return 'AssertException: ' + this.message;
47};
48
49function assert(exp, message) {
50  if (!exp) {
51    throw new AssertException(message);
52  }
53}
54
55if (!String.prototype.trim) {
56  String.prototype.trim = function() {
57    return this.replace(/^\s+|\s+$/g, '');
58  };
59}
60
61Diagram.themes = {};
62function registerTheme(name, theme) {
63  Diagram.themes[name] = theme;
64}
65
66/******************
67 * Drawing extras
68 ******************/
69
70function getCenterX(box) {
71  return box.x + box.width / 2;
72}
73
74function getCenterY(box) {
75  return box.y + box.height / 2;
76}
77
78/******************
79 * SVG Path extras
80 ******************/
81
82function clamp(x, min, max) {
83  if (x < min) {
84    return min;
85  }
86  if (x > max) {
87    return max;
88  }
89  return x;
90}
91
92function wobble(x1, y1, x2, y2) {
93  assert(_.all([x1,x2,y1,y2], _.isFinite), 'x1,x2,y1,y2 must be numeric');
94
95  // Wobble no more than 1/25 of the line length
96  var factor = Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) / 25;
97
98  // Distance along line where the control points are
99  // Clamp between 20% and 80% so any arrow heads aren't angled too much
100  var r1 = clamp(Math.random(), 0.2, 0.8);
101  var r2 = clamp(Math.random(), 0.2, 0.8);
102
103  var xfactor = Math.random() > 0.5 ? factor : -factor;
104  var yfactor = Math.random() > 0.5 ? factor : -factor;
105
106  var p1 = {
107    x: (x2 - x1) * r1 + x1 + xfactor,
108    y: (y2 - y1) * r1 + y1 + yfactor
109  };
110
111  var p2 = {
112    x: (x2 - x1) * r2 + x1 - xfactor,
113    y: (y2 - y1) * r2 + y1 - yfactor
114  };
115
116  return 'C' + p1.x.toFixed(1) + ',' + p1.y.toFixed(1) + // start control point
117         ' ' + p2.x.toFixed(1) + ',' + p2.y.toFixed(1) + // end control point
118         ' ' + x2.toFixed(1) + ',' + y2.toFixed(1);      // end point
119}
120
121/**
122 * Draws a wobbly (hand drawn) rect
123 */
124function handRect(x, y, w, h) {
125  assert(_.all([x, y, w, h], _.isFinite), 'x, y, w, h must be numeric');
126  return 'M' + x + ',' + y +
127   wobble(x, y, x + w, y) +
128   wobble(x + w, y, x + w, y + h) +
129   wobble(x + w, y + h, x, y + h) +
130   wobble(x, y + h, x, y);
131}
132
133/**
134 * Draws a wobbly (hand drawn) line
135 */
136function handLine(x1, y1, x2, y2) {
137  assert(_.all([x1,x2,y1,y2], _.isFinite), 'x1,x2,y1,y2 must be numeric');
138  return 'M' + x1.toFixed(1) + ',' + y1.toFixed(1) + wobble(x1, y1, x2, y2);
139}
140
141/******************
142 * BaseTheme
143 ******************/
144
145var BaseTheme = function(diagram, options) {
146  this.init(diagram, options);
147};
148
149_.extend(BaseTheme.prototype, {
150
151  // Init called while creating the Theme
152  init: function(diagram, options) {
153    this.diagram = diagram;
154
155    this.actorsHeight_  = 0;
156    this.signalsHeight_ = 0;
157    this.title_ = undefined; // hack - This should be somewhere better
158  },
159
160  setupPaper: function(container) {},
161
162  draw: function(container) {
163    this.setupPaper(container);
164
165    this.layout();
166
167    var titleHeight = this.title_ ? this.title_.height : 0;
168    var y = DIAGRAM_MARGIN + titleHeight;
169
170    this.drawTitle();
171    this.drawActors(y);
172    this.drawSignals(y + this.actorsHeight_);
173  },
174
175  layout: function() {
176    // Local copies
177    var diagram = this.diagram;
178    var font    = this.font_;
179    var actors  = diagram.actors;
180    var signals = diagram.signals;
181
182    diagram.width  = 0; // min width
183    diagram.height = 0; // min height
184
185    // Setup some layout stuff
186    if (diagram.title) {
187      var title = this.title_ = {};
188      var bb = this.textBBox(diagram.title, font);
189      title.textBB = bb;
190      title.message = diagram.title;
191
192      title.width  = bb.width  + (TITLE_PADDING + TITLE_MARGIN) * 2;
193      title.height = bb.height + (TITLE_PADDING + TITLE_MARGIN) * 2;
194      title.x = DIAGRAM_MARGIN;
195      title.y = DIAGRAM_MARGIN;
196
197      diagram.width  += title.width;
198      diagram.height += title.height;
199    }
200
201    _.each(actors, function(a) {
202      var bb = this.textBBox(a.name, font);
203      a.textBB = bb;
204
205      a.x = 0; a.y = 0;
206      a.width  = bb.width  + (ACTOR_PADDING + ACTOR_MARGIN) * 2;
207      a.height = bb.height + (ACTOR_PADDING + ACTOR_MARGIN) * 2;
208
209      a.distances = [];
210      a.paddingRight = 0;
211      this.actorsHeight_ = Math.max(a.height, this.actorsHeight_);
212    }, this);
213
214    function actorEnsureDistance(a, b, d) {
215      assert(a < b, 'a must be less than or equal to b');
216
217      if (a < 0) {
218        // Ensure b has left margin
219        b = actors[b];
220        b.x = Math.max(d - b.width / 2, b.x);
221      } else if (b >= actors.length) {
222        // Ensure a has right margin
223        a = actors[a];
224        a.paddingRight = Math.max(d, a.paddingRight);
225      } else {
226        a = actors[a];
227        a.distances[b] = Math.max(d, a.distances[b] ? a.distances[b] : 0);
228      }
229    }
230
231    _.each(signals, function(s) {
232      // Indexes of the left and right actors involved
233      var a;
234      var b;
235
236      var bb = this.textBBox(s.message, font);
237
238      //var bb = t.attr("text", s.message).getBBox();
239      s.textBB = bb;
240      s.width   = bb.width;
241      s.height  = bb.height;
242
243      var extraWidth = 0;
244
245      if (s.type == 'Signal') {
246
247        s.width  += (SIGNAL_MARGIN + SIGNAL_PADDING) * 2;
248        s.height += (SIGNAL_MARGIN + SIGNAL_PADDING) * 2;
249
250        if (s.isSelf()) {
251          // TODO Self signals need a min height
252          a = s.actorA.index;
253          b = a + 1;
254          s.width += SELF_SIGNAL_WIDTH;
255        } else {
256          a = Math.min(s.actorA.index, s.actorB.index);
257          b = Math.max(s.actorA.index, s.actorB.index);
258        }
259
260      } else if (s.type == 'Note') {
261        s.width  += (NOTE_MARGIN + NOTE_PADDING) * 2;
262        s.height += (NOTE_MARGIN + NOTE_PADDING) * 2;
263
264        // HACK lets include the actor's padding
265        extraWidth = 2 * ACTOR_MARGIN;
266
267        if (s.placement == PLACEMENT.LEFTOF) {
268          b = s.actor.index;
269          a = b - 1;
270        } else if (s.placement == PLACEMENT.RIGHTOF) {
271          a = s.actor.index;
272          b = a + 1;
273        } else if (s.placement == PLACEMENT.OVER && s.hasManyActors()) {
274          // Over multiple actors
275          a = Math.min(s.actor[0].index, s.actor[1].index);
276          b = Math.max(s.actor[0].index, s.actor[1].index);
277
278          // We don't need our padding, and we want to overlap
279          extraWidth = -(NOTE_PADDING * 2 + NOTE_OVERLAP * 2);
280
281        } else if (s.placement == PLACEMENT.OVER) {
282          // Over single actor
283          a = s.actor.index;
284          actorEnsureDistance(a - 1, a, s.width / 2);
285          actorEnsureDistance(a, a + 1, s.width / 2);
286          this.signalsHeight_ += s.height;
287
288          return; // Bail out early
289        }
290      } else {
291        throw new Error('Unhandled signal type:' + s.type);
292      }
293
294      actorEnsureDistance(a, b, s.width + extraWidth);
295      this.signalsHeight_ += s.height;
296    }, this);
297
298    // Re-jig the positions
299    var actorsX = 0;
300    _.each(actors, function(a) {
301      a.x = Math.max(actorsX, a.x);
302
303      // TODO This only works if we loop in sequence, 0, 1, 2, etc
304      _.each(a.distances, function(distance, b) {
305        // lodash (and possibly others) do not like sparse arrays
306        // so sometimes they return undefined
307        if (typeof distance == 'undefined') {
308          return;
309        }
310
311        b = actors[b];
312        distance = Math.max(distance, a.width / 2, b.width / 2);
313        b.x = Math.max(b.x, a.x + a.width / 2 + distance - b.width / 2);
314      });
315
316      actorsX = a.x + a.width + a.paddingRight;
317    }, this);
318
319    diagram.width = Math.max(actorsX, diagram.width);
320
321    // TODO Refactor a little
322    diagram.width  += 2 * DIAGRAM_MARGIN;
323    diagram.height += 2 * DIAGRAM_MARGIN + 2 * this.actorsHeight_ + this.signalsHeight_;
324
325    return this;
326  },
327
328  // TODO Instead of one textBBox function, create a function for each element type, e.g
329  //      layout_title, layout_actor, etc that returns it's bounding box
330  textBBox: function(text, font) {},
331
332  drawTitle: function() {
333    var title = this.title_;
334    if (title) {
335      this.drawTextBox(title, title.message, TITLE_MARGIN, TITLE_PADDING, this.font_, ALIGN_LEFT);
336    }
337  },
338
339  drawActors: function(offsetY) {
340    var y = offsetY;
341    _.each(this.diagram.actors, function(a) {
342      // Top box
343      this.drawActor(a, y, this.actorsHeight_);
344
345      // Bottom box
346      this.drawActor(a, y + this.actorsHeight_ + this.signalsHeight_, this.actorsHeight_);
347
348      // Veritical line
349      var aX = getCenterX(a);
350      this.drawLine(
351       aX, y + this.actorsHeight_ - ACTOR_MARGIN,
352       aX, y + this.actorsHeight_ + ACTOR_MARGIN + this.signalsHeight_);
353    }, this);
354  },
355
356  drawActor: function(actor, offsetY, height) {
357    actor.y      = offsetY;
358    actor.height = height;
359    this.drawTextBox(actor, actor.name, ACTOR_MARGIN, ACTOR_PADDING, this.font_, ALIGN_CENTER);
360  },
361
362  drawSignals: function(offsetY) {
363    var y = offsetY;
364    _.each(this.diagram.signals, function(s) {
365      // TODO Add debug mode, that draws padding/margin box
366      if (s.type == 'Signal') {
367        if (s.isSelf()) {
368          this.drawSelfSignal(s, y);
369        } else {
370          this.drawSignal(s, y);
371        }
372
373      } else if (s.type == 'Note') {
374        this.drawNote(s, y);
375      }
376
377      y += s.height;
378    }, this);
379  },
380
381  drawSelfSignal: function(signal, offsetY) {
382      assert(signal.isSelf(), 'signal must be a self signal');
383
384      var textBB = signal.textBB;
385      var aX = getCenterX(signal.actorA);
386
387      var x = aX + SELF_SIGNAL_WIDTH + SIGNAL_PADDING;
388      var y = offsetY + SIGNAL_PADDING + signal.height / 2 + textBB.y;
389
390      this.drawText(x, y, signal.message, this.font_, ALIGN_LEFT);
391
392      var y1 = offsetY + SIGNAL_MARGIN + SIGNAL_PADDING;
393      var y2 = y1 + signal.height - 2 * SIGNAL_MARGIN - SIGNAL_PADDING;
394
395      // Draw three lines, the last one with a arrow
396      this.drawLine(aX, y1, aX + SELF_SIGNAL_WIDTH, y1, signal.linetype);
397      this.drawLine(aX + SELF_SIGNAL_WIDTH, y1, aX + SELF_SIGNAL_WIDTH, y2, signal.linetype);
398      this.drawLine(aX + SELF_SIGNAL_WIDTH, y2, aX, y2, signal.linetype, signal.arrowtype);
399    },
400
401  drawSignal: function(signal, offsetY) {
402    var aX = getCenterX(signal.actorA);
403    var bX = getCenterX(signal.actorB);
404
405    // Mid point between actors
406    var x = (bX - aX) / 2 + aX;
407    var y = offsetY + SIGNAL_MARGIN + 2 * SIGNAL_PADDING;
408
409    // Draw the text in the middle of the signal
410    this.drawText(x, y, signal.message, this.font_, ALIGN_CENTER);
411
412    // Draw the line along the bottom of the signal
413    y = offsetY + signal.height - SIGNAL_MARGIN - SIGNAL_PADDING;
414    this.drawLine(aX, y, bX, y, signal.linetype, signal.arrowtype);
415  },
416
417  drawNote: function(note, offsetY) {
418    note.y = offsetY;
419    var actorA = note.hasManyActors() ? note.actor[0] : note.actor;
420    var aX = getCenterX(actorA);
421    switch (note.placement) {
422    case PLACEMENT.RIGHTOF:
423      note.x = aX + ACTOR_MARGIN;
424    break;
425    case PLACEMENT.LEFTOF:
426      note.x = aX - ACTOR_MARGIN - note.width;
427    break;
428    case PLACEMENT.OVER:
429      if (note.hasManyActors()) {
430        var bX = getCenterX(note.actor[1]);
431        var overlap = NOTE_OVERLAP + NOTE_PADDING;
432        note.x = Math.min(aX, bX) - overlap;
433        note.width = (Math.max(aX, bX) + overlap) - note.x;
434      } else {
435        note.x = aX - note.width / 2;
436      }
437    break;
438    default:
439      throw new Error('Unhandled note placement: ' + note.placement);
440  }
441    return this.drawTextBox(note, note.message, NOTE_MARGIN, NOTE_PADDING, this.font_, ALIGN_LEFT);
442  },
443
444  /**
445   * Draw text surrounded by a box
446   */
447  drawTextBox: function(box, text, margin, padding, font, align) {
448    var x = box.x + margin;
449    var y = box.y + margin;
450    var w = box.width  - 2 * margin;
451    var h = box.height - 2 * margin;
452
453    // Draw inner box
454    this.drawRect(x, y, w, h);
455
456    // Draw text (in the center)
457    if (align == ALIGN_CENTER) {
458      x = getCenterX(box);
459      y = getCenterY(box);
460    } else {
461      x += padding;
462      y += padding;
463    }
464
465    return this.drawText(x, y, text, font, align);
466  }
467});
468