/* eslint-disable max-lines */ /* eslint-disable sort-keys */ // Maybe later import BaseTheme from './BaseTheme.mjs'; import Handlee from '../../fonts/HandleeFontData.mjs'; import Random from '../../core/Random.mjs'; const FONT = Handlee.name; const FONT_FAMILY = FONT + ',cursive'; const LINE_HEIGHT = 1.5; const MAX_CHAOS = 5; const PENCIL = { normal: { 'stroke': 'rgba(0,0,0,0.7)', 'stroke-width': 0.8, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', }, thick: { 'stroke': 'rgba(0,0,0,0.8)', 'stroke-width': 1.2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round', }, }; const NOTE_ATTRS = { 'font-family': FONT_FAMILY, 'font-size': 8, 'line-height': LINE_HEIGHT, }; const DIVIDER_LABEL_ATTRS = { 'font-family': FONT_FAMILY, 'font-size': 8, 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', }; export const RIGHT = {}; export const LEFT = {}; class SketchWavePattern { constructor(width, handedness) { this.deltas = [ +0.0, -0.3, -0.6, -0.75, -0.45, +0.0, +0.45, +0.75, +0.6, +0.3, ]; if(handedness !== RIGHT) { this.deltas.reverse(); } this.partWidth = 6 / this.deltas.length; } getDelta(p) { return this.deltas[p % this.deltas.length] + Math.sin(p * 0.03) * 0.5; } } function clamp(v, low, high) { return Math.max(low, Math.min(high, v)); } export default class SketchTheme extends BaseTheme { constructor(svg, handedness = RIGHT) { super(svg, { 'font-family': FONT, 'font-size': 8, 'line-height': LINE_HEIGHT, }); this.handedness = (handedness === RIGHT) ? 1 : -1; this.random = new Random(); const wave = new SketchWavePattern(4, handedness); const sharedBlockSection = { padding: { top: 3, bottom: 2, }, tag: { padding: { top: 2, left: 3, right: 5, bottom: 0, }, boxRenderer: this.renderTag.bind(this), labelAttrs: { 'font-family': FONT_FAMILY, 'font-weight': 'bold', 'font-size': 9, 'line-height': LINE_HEIGHT, 'text-anchor': 'left', }, }, label: { minHeight: 6, padding: { top: 2, left: 5, right: 3, bottom: 1, }, labelAttrs: { 'font-family': FONT_FAMILY, 'font-size': 8, 'line-height': LINE_HEIGHT, 'text-anchor': 'left', }, }, }; Object.assign(this, { titleMargin: 10, outerMargin: 5, agentMargin: 10, actionMargin: 10, minActionMargin: 3, agentLineHighlightRadius: 4, agentCap: { box: { padding: { top: 5, left: 10, right: 10, bottom: 5, }, arrowBottom: 5 + 12 * 1.3 / 2, labelAttrs: { 'font-family': FONT_FAMILY, 'font-size': 12, 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', }, boxRenderer: this.renderBox.bind(this), }, person: { padding: { top: 20, left: 10, right: 10, bottom: 5, }, arrowBottom: 5 + 12 * 1.3 / 2, labelAttrs: { 'font-family': FONT_FAMILY, 'font-size': 12, 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', }, boxRenderer: this.renderPerson.bind(this), }, database: { padding: { top: 12, left: 10, right: 10, bottom: 2, }, arrowBottom: 5 + 12 * 1.3 / 2, boxRenderer: this.renderDB.bind(this), labelAttrs: { 'font-family': FONT, 'font-size': 12, 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', }, }, cross: { size: 15, render: this.renderCross.bind(this), }, bar: { height: 6, render: this.renderBar.bind(this), }, fade: { width: Math.ceil(MAX_CHAOS * 2 + 2), height: 6, extend: Math.ceil(MAX_CHAOS * 0.3 + 1), }, none: { height: 10, }, }, connect: { loopbackRadius: 6, arrow: { 'single': { width: 5, height: 6, attrs: Object.assign({ 'fill': 'rgba(0,0,0,0.9)', }, PENCIL.normal), render: this.renderArrowHead.bind(this), }, 'double': { width: 4, height: 8, attrs: Object.assign({ 'fill': 'none', }, PENCIL.normal), render: this.renderArrowHead.bind(this), }, 'fade': { short: 0, size: 12, }, 'cross': { short: 5, radius: 3, render: this.renderCross.bind(this), }, }, label: { padding: 6, margin: {top: 2, bottom: 1}, attrs: { 'font-family': FONT_FAMILY, 'font-size': 8, 'line-height': LINE_HEIGHT, 'text-anchor': 'middle', }, loopbackAttrs: { 'font-family': FONT_FAMILY, 'font-size': 8, 'line-height': LINE_HEIGHT, }, }, source: { radius: 1, render: svg.circleFactory({ 'fill': '#000000', 'stroke': '#000000', 'stroke-width': 1, }), }, mask: { padding: { top: 0, left: 3, right: 3, bottom: 1, }, }, }, agentLineAttrs: { '': Object.assign({ 'fill': 'none', }, PENCIL.normal), 'red': { 'stroke': 'rgba(200,40,0,0.8)', }, }, blocks: { 'ref': { margin: { top: 0, bottom: 0, }, boxRenderer: this.renderRefBlock.bind(this), section: sharedBlockSection, }, '': { margin: { top: 0, bottom: 0, }, boxRenderer: this.renderBlock.bind(this), collapsedBoxRenderer: this.renderMinBlock.bind(this), section: sharedBlockSection, sepRenderer: this.renderSeparator.bind(this), }, }, notes: { 'text': { margin: {top: 0, left: 6, right: 6, bottom: 0}, padding: {top: 2, left: 2, right: 2, bottom: 2}, overlap: {left: 10, right: 10}, boxRenderer: svg.boxFactory({ 'fill': '#FFFFFF', }), labelAttrs: NOTE_ATTRS, }, 'note': { margin: {top: 0, left: 5, right: 5, bottom: 0}, padding: {top: 5, left: 5, right: 10, bottom: 5}, overlap: {left: 10, right: 10}, boxRenderer: this.renderNote.bind(this), labelAttrs: NOTE_ATTRS, }, 'state': { margin: {top: 0, left: 5, right: 5, bottom: 0}, padding: {top: 7, left: 7, right: 7, bottom: 7}, overlap: {left: 10, right: 10}, boxRenderer: this.renderState.bind(this), labelAttrs: NOTE_ATTRS, }, }, dividers: { '': { labelAttrs: DIVIDER_LABEL_ATTRS, padding: {top: 2, left: 5, right: 5, bottom: 2}, extend: 0, margin: 0, render: () => ({}), }, 'line': { labelAttrs: DIVIDER_LABEL_ATTRS, padding: {top: 2, left: 5, right: 5, bottom: 2}, extend: 10, margin: 0, render: this.renderLineDivider.bind(this), }, 'delay': { labelAttrs: DIVIDER_LABEL_ATTRS, padding: {top: 2, left: 5, right: 5, bottom: 2}, extend: 0, margin: 0, render: this.renderDelayDivider.bind(this, { dotSize: 1, gapSize: 2, }), }, 'tear': { labelAttrs: DIVIDER_LABEL_ATTRS, padding: {top: 2, left: 5, right: 5, bottom: 2}, extend: 10, margin: 10, render: this.renderTearDivider.bind(this, { fadeBegin: 5, fadeSize: 10, pattern: wave, lineAttrs: PENCIL.normal, }), }, }, }); this.addConnectLine('solid', {attrs: PENCIL.normal}); this.addConnectLine('dash', {attrs: { 'stroke-dasharray': '4, 2', }}); this.addConnectLine('wave', { attrs: { 'stroke-linejoin': 'round', 'stroke-linecap': 'round', }, pattern: wave, }); } reset() { this.random.reset(); } addDefs(builder, textBuilder) { builder('sketch_font', () => { const style = this.svg.el('style', null); // Font must be embedded for exporting as SVG / PNG style.text( '@font-face{' + 'font-family:' + Handlee.name + ';' + 'src:url("data:font/woff2;base64,' + Handlee.woff2 + '");' + '}' ); return style; }); super.addDefs(builder, textBuilder); } vary(range, centre = 0) { if(!range) { return centre; } // Cosine distribution [-pi/2 pi/2] scaled to [-range range] const rand = this.random.nextFloat(); // [0 1) return centre + Math.asin(rand * 2 - 1) * 2 * range / Math.PI; } lineNodes(p1, p2, { var1 = 1, var2 = 1, varX = 1, varY = 1, move = true, }) { const length = Math.sqrt( (p2.x - p1.x) * (p2.x - p1.x) + (p2.y - p1.y) * (p2.y - p1.y) ); const rough = Math.min(Math.sqrt(length) * 0.2, MAX_CHAOS); const x1 = this.vary(var1 * varX * rough, p1.x); const y1 = this.vary(var1 * varY * rough, p1.y); const x2 = this.vary(var2 * varX * rough, p2.x); const y2 = this.vary(var2 * varY * rough, p2.y); // -1 = p1 higher, 1 = p2 higher const upper = clamp((y1 - y2) / (Math.abs(x1 - x2) + 0.001), -1, 1); const frac = upper / 6 + 0.5; /* * Line curve is to top / left (simulates right-handed drawing) * or top / right (left-handed) */ const curveX = this.vary(0.5, 0.5) * rough; const curveY = this.vary(0.5, 0.5) * rough; const xc = x1 * (1 - frac) + x2 * frac - curveX * this.handedness; const yc = y1 * (1 - frac) + y2 * frac - curveY; const nodes = ( (move ? ('M' + x1 + ' ' + y1) : '') + 'C' + xc + ' ' + yc + ',' + x2 + ' ' + y2 + ',' + x2 + ' ' + y2 ); return { nodes, p1: {x: x1, y: y1}, p2: {x: x2, y: y2}, }; } renderLine(p1, p2, lineOptions) { const line = this.lineNodes(p1, p2, lineOptions); return this.svg.el('path') .attrs({ 'd': line.nodes, 'fill': 'none', 'stroke-dasharray': lineOptions.dash ? '6, 5' : 'none', }) .attrs(lineOptions.attrs || ( lineOptions.thick ? PENCIL.thick : PENCIL.normal )); } boxNodes({x, y, width, height}) { const lT = this.lineNodes( {x, y}, {x: x + width, y}, {} ); const lB = this.lineNodes( {x: x + width, y: y + height}, {x, y: y + height}, {move: false} ); const lR = this.lineNodes( lT.p2, lB.p1, {var1: 0, var2: 0, move: false} ); const lL = this.lineNodes( lB.p2, lT.p1, {var1: 0, var2: 0.3, move: false} ); return lT.nodes + lR.nodes + lB.nodes + lL.nodes; } renderBox(position, {fill = null, thick = false, attrs = null} = {}) { return this.svg.el('path') .attrs({ 'd': this.boxNodes(position), 'fill': fill || '#FFFFFF', }) .attrs(attrs || (thick ? PENCIL.thick : PENCIL.normal)); } renderPerson(position) { const sx = 18 / 2; const sy = 15; const iconAttrs = Object.assign({'fill': 'none'}, PENCIL.normal); const cx = position.x + position.width / 2; const v = this.vary.bind(this); const lean = v(0.1, 0.05 * this.handedness); const skew = v(0.1, 0.05 * this.handedness); const shoulders = v(0.05, 0.35); return this.svg.el('g').add( this.svg.el('path') .attr('d', ( 'M' + (cx - sx) + ' ' + (position.y + sy) + 'c' + (sx * lean) + ' ' + (-sy * (shoulders - skew)) + ' ' + (sx * (2 + lean)) + ' ' + (-sy * (shoulders + skew)) + ' ' + (sx * 2) + ' 0' )) .attrs(iconAttrs), this.svg.el('path') .attr('d', ( 'M' + cx + ' ' + position.y + 'c' + (sx * v(0.05, 0.224)) + ' ' + v(0.02, 0) + ' ' + (sx * v(0.07, 0.4)) + ' ' + (sy * v(0.02, 0.1)) + ' ' + (sx * 0.4) + ' ' + (sy * v(0.05, 0.275)) + 's' + (-sx * v(0.05, 0.176)) + ' ' + (sy * 0.35) + ' ' + (-sx * 0.4) + ' ' + (sy * v(0.05, 0.35)) + 's' + (-sx * v(0.07, 0.4)) + ' ' + (-sy * 0.175) + ' ' + (-sx * 0.4) + ' ' + (-sy * v(0.05, 0.35)) + 's' + (sx * v(0.05, 0.176)) + ' ' + (-sy * 0.275) + ' ' + (sx * v(0.05, 0.4)) + ' ' + (-sy * v(0.02, 0.275)) )) .attrs(iconAttrs), this.renderBox({ height: position.height - sy, width: position.width, x: position.x, y: position.y + sy, }) ); } renderDB(pos) { const tilt = 5; const tiltC = tilt * 1.2; const l1 = this.lineNodes( {x: pos.x, y: pos.y + tilt}, {x: pos.x, y: pos.y + pos.height - tilt}, {} ); const l2 = this.lineNodes( {x: pos.x + pos.width, y: pos.y + pos.height - tilt}, {x: pos.x + pos.width, y: pos.y + tilt}, {move: false} ); const v = this.vary.bind(this); const cx = pos.x + pos.width / 2; const dx = -pos.width * this.handedness / 2; const topX1 = cx - dx * v(0.03, 1.02); const topY1 = pos.y + tilt * v(0.15, 1); const topX2 = cx + dx * v(0.03, 1.02); const topY2 = pos.y + tilt * v(0.15, 1); return this.svg.el('g').add( this.svg.el('path') .attr('d', ( l1.nodes + 'C' + l1.p2.x + ' ' + (l1.p2.y + v(0.1, tiltC)) + ' ' + l2.p1.x + ' ' + (l2.p1.y + v(0.1, tiltC)) + ' ' + l2.p1.x + ' ' + l2.p1.y + l2.nodes )) .attrs(PENCIL.normal) .attr('fill', '#FFFFFF'), this.svg.el('path') .attr('d', ( 'M' + topX1 + ' ' + topY1 + 'C' + (topX1 + dx * 0.2) + ' ' + (topY1 - v(0.2, tiltC)) + ' ' + topX2 + ' ' + (topY2 - v(0.2, tiltC)) + ' ' + topX2 + ' ' + topY2 + 'S' + topX1 + ' ' + (topY1 + v(0.2, tiltC)) + ' ' + v(1, topX1) + ' ' + v(0.5, topY1) )) .attrs(PENCIL.normal) .attr('fill', '#FFFFFF') ); } renderNote({x, y, width, height}) { const flickSize = 5; const lT = this.lineNodes( {x, y}, {x: x + width - flickSize, y}, {} ); const lF = this.lineNodes( lT.p2, {x: x + width, y: y + flickSize}, {move: false, var1: 0} ); const lB = this.lineNodes( {x: x + width, y: y + height}, {x, y: y + height}, {move: false} ); const lR = this.lineNodes( lF.p2, lB.p1, {var1: 0, var2: 0, move: false} ); const lL = this.lineNodes( lB.p2, lT.p1, {var1: 0, var2: 0.3, move: false} ); const lF1 = this.lineNodes( lF.p1, {x: x + width - flickSize, y: y + flickSize}, {var1: 0.3} ); const lF2 = this.lineNodes( lF1.p2, lF.p2, {var1: 0, move: false} ); return this.svg.el('g').add( this.svg.el('path') .attrs({ 'd': ( lT.nodes + lF.nodes + lR.nodes + lB.nodes + lL.nodes ), 'fill': '#FFFFFF', }) .attrs(PENCIL.normal), this.svg.el('path') .attrs({ 'd': lF1.nodes + lF2.nodes, 'fill': 'none', }) .attrs(PENCIL.normal) ); } renderLineDivider({x, y, labelWidth, width, height}) { let shape = null; const yPos = y + height / 2; if(labelWidth > 0) { shape = this.svg.el('g').add( this.renderLine( {x, y: yPos}, {x: x + (width - labelWidth) / 2, y: yPos}, {} ), this.renderLine( {x: x + (width + labelWidth) / 2, y: yPos}, {x: x + width, y: yPos}, {} ) ); } else { shape = this.renderLine( {x, y: yPos}, {x: x + width, y: yPos}, {} ); } return {shape}; } renderFlatConnect(pattern, attrs, {x1, y1, x2, y2}) { if(pattern) { const x1v = x1 + this.vary(0.3); const x2v = x2 + this.vary(0.3); const y1v = y1 + this.vary(1); const y2v = y2 + this.vary(1); return { shape: this.svg.el('path') .attr('d', this.svg.patternedLine(pattern) .move(x1v, y1v) .line(x2v, y2v) .cap() .asPath()) .attrs(attrs), p1: {x: x1v, y: y1v}, p2: {x: x2v, y: y2v}, }; } else { const ln = this.lineNodes( {x: x1, y: y1}, {x: x2, y: y2}, {varX: 0.3} ); return { shape: this.svg.el('path').attr('d', ln.nodes).attrs(attrs), p1: ln.p1, p2: ln.p2, }; } } renderRevConnect(pattern, attrs, {x1, y1, x2, y2, xR}) { if(pattern) { const x1v = x1 + this.vary(0.3); const x2v = x2 + this.vary(0.3); const y1v = y1 + this.vary(1); const y2v = y2 + this.vary(1); return { shape: this.svg.el('path') .attr('d', this.svg.patternedLine(pattern) .move(x1v, y1v) .line(xR, y1) .arc(xR, (y1 + y2) / 2, Math.PI) .line(x2v, y2v) .cap() .asPath()) .attrs(attrs), p1: {x: x1v, y: y1v}, p2: {x: x2v, y: y2v}, }; } else { const variance = Math.min((xR - x1) * 0.06, 3); const overshoot = Math.min((xR - x1) * 0.5, 6); const p1x = x1 + this.vary(variance, -1); const p1y = y1 + this.vary(variance, -1); const b1x = xR - overshoot * this.vary(0.2, 1); const b1y = y1 - this.vary(1, 2); const p2x = xR; const p2y = y1 + this.vary(1, 1); const b2x = xR; const b2y = y2 + this.vary(2); const p3x = x2 + this.vary(variance, -1); const p3y = y2 + this.vary(variance, -1); return { shape: this.svg.el('path') .attr('d', ( 'M' + p1x + ' ' + p1y + 'C' + p1x + ' ' + p1y + ',' + b1x + ' ' + b1y + ',' + p2x + ' ' + p2y + 'S' + b2x + ' ' + b2y + ',' + p3x + ' ' + p3y )) .attrs(attrs), p1: {x: p1x, y: p1y}, p2: {x: p3x, y: p3y}, }; } } renderArrowHead(attrs, {x, y, width, height, dir}) { const w = width * this.vary(0.2, 1); const h = height * this.vary(0.3, 1); const wx = w * dir.dx; const wy = w * dir.dy; const hy = h * 0.5 * dir.dx; const hx = -h * 0.5 * dir.dy; const l1 = this.lineNodes( {x: x + wx - hx, y: y + wy - hy}, {x, y}, {var1: 2.0, var2: 0.2} ); const l2 = this.lineNodes( l1.p2, {x: x + wx + hx, y: y + wy + hy}, {var1: 0, var2: 2.0, move: false} ); const l3 = (attrs.fill === 'none') ? {nodes: ''} : this.lineNodes( l2.p2, l1.p1, {var1: 0, var2: 0, move: false} ); return this.svg.el('path') .attr('d', l1.nodes + l2.nodes + l3.nodes) .attrs(attrs); } renderState({x, y, width, height}) { const dx = Math.min(width * 0.06, 3); const dy = Math.min(height * 0.06, 3); const tlX = x + dx * this.vary(0.6, 1); const tlY = y + dy * this.vary(0.6, 1); const trX = x + width - dx * this.vary(0.6, 1); const trY = y + dy * this.vary(0.6, 1); const blX = x + dx * this.vary(0.6, 1); const blY = y + height - dy * this.vary(0.6, 1); const brX = x + width - dx * this.vary(0.6, 1); const brY = y + height - dy * this.vary(0.6, 1); const mX = x + width / 2; const mY = y + height / 2; const cx = dx * this.vary(0.2, 1.2); const cy = dy * this.vary(0.2, 1.2); const bentT = y - Math.min(width * 0.005, 1); const bentB = y + height - Math.min(width * 0.01, 2); const bentL = x - Math.min(height * 0.01, 2) * this.handedness; const bentR = bentL + width; return this.svg.el('path') .attr('d', ( 'M' + tlX + ' ' + tlY + 'C' + (tlX + cx) + ' ' + (tlY - cy) + ',' + (mX - width * this.vary(0.03, 0.3)) + ' ' + bentT + ',' + mX + ' ' + bentT + 'S' + (trX - cx) + ' ' + (trY - cy) + ',' + trX + ' ' + trY + 'S' + bentR + ' ' + (mY - height * this.vary(0.03, 0.3)) + ',' + bentR + ' ' + mY + 'S' + (brX + cx) + ' ' + (brY - cy) + ',' + brX + ' ' + brY + 'S' + (mX + width * this.vary(0.03, 0.3)) + ' ' + bentB + ',' + mX + ' ' + bentB + 'S' + (blX + cx) + ' ' + (blY + cy) + ',' + blX + ' ' + blY + 'S' + bentL + ' ' + (mY + height * this.vary(0.03, 0.3)) + ',' + bentL + ' ' + mY + 'S' + (tlX - cx) + ' ' + (tlY + cy) + ',' + tlX + ' ' + tlY + 'Z' )) .attr('fill', '#FFFFFF') .attrs(PENCIL.normal); } renderRefBlock(position) { const nodes = this.boxNodes(position); return { shape: this.svg.el('path') .attrs({'d': nodes, 'fill': 'none'}) .attrs(PENCIL.thick), mask: this.svg.el('path') .attrs({'d': nodes, 'fill': '#000000'}), fill: this.svg.el('path') .attrs({'d': nodes, 'fill': '#FFFFFF'}), }; } renderBlock(position) { return this.renderBox(position, {fill: 'none', thick: true}); } renderMinBlock(position) { return this.renderRefBlock(position); } renderTag({x, y, width, height}) { const x2 = x + width; const y2 = y + height; const l1 = this.lineNodes( {x: x2 + 3, y}, {x: x2 - 2, y: y2}, {} ); const l2 = this.lineNodes( l1.p2, {x, y: y2 + 1}, {var1: 0, move: false} ); const line = l1.nodes + l2.nodes; return this.svg.el('g').add( this.svg.el('path') .attrs({ 'd': line + 'L' + x + ' ' + y, 'fill': '#FFFFFF', }), this.svg.el('path') .attrs({ 'd': line, 'fill': '#FFFFFF', }) .attrs(PENCIL.normal) ); } renderSeparator({x1, y1, x2, y2}) { return this.renderLine( {x: x1, y: y1}, {x: x2, y: y2}, {thick: true, dash: true} ); } renderBar({x, y, width, height}) { return this.renderBox({x, y, width, height}, {fill: '#000000'}); } renderCross({x, y, radius}) { const r1 = this.vary(0.2, 1) * radius; const l1 = this.lineNodes( {x: x - r1, y: y - r1}, {x: x + r1, y: y + r1}, {} ); const r2 = this.vary(0.2, 1) * radius; const l2 = this.lineNodes( {x: x + r2, y: y - r2}, {x: x - r2, y: y + r2}, {} ); return this.svg.el('path') .attrs({ 'd': l1.nodes + l2.nodes, 'fill': 'none', }) .attrs(PENCIL.normal); } renderAgentLine({x, y0, y1, width, className, options}) { const attrs = this.optionsAttributes(this.agentLineAttrs, options); if(width > 0) { return this.renderBox({ x: x - width / 2, y: y0, width, height: y1 - y0, }, {fill: 'none', attrs}).setClass(className); } else { return this.renderLine( {x, y: y0}, {x, y: y1}, {varY: 0.3, attrs} ).setClass(className); } } } export class Factory { constructor(handedness = RIGHT) { const right = (handedness === RIGHT); this.name = right ? 'sketch' : 'sketch left handed'; this.handedness = handedness; } build(svg) { return new SketchTheme(svg, this.handedness); } } Object.assign(Factory, {RIGHT, LEFT});