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