UNPKG

jointjs

Version:

JavaScript diagramming library

1,418 lines (1,136 loc) 86.2 kB
// Vectorizer. // ----------- // A tiny library for making your life easier when dealing with SVG. // The only Vectorizer dependency is the Geometry library. import * as g from '../g/index.mjs'; const V = (function() { var hasSvg = typeof window === 'object' && !!( window.SVGAngle || document.implementation.hasFeature('http://www.w3.org/TR/SVG11/feature#BasicStructure', '1.1') ); // SVG support is required. if (!hasSvg) { // Return a function that throws an error when it is used. return function() { throw new Error('SVG is required to use Vectorizer.'); }; } // XML namespaces. var ns = { svg: 'http://www.w3.org/2000/svg', xmlns: 'http://www.w3.org/2000/xmlns/', xml: 'http://www.w3.org/XML/1998/namespace', xlink: 'http://www.w3.org/1999/xlink', xhtml: 'http://www.w3.org/1999/xhtml' }; var SVGVersion = '1.1'; // Declare shorthands to the most used math functions. var math = Math; var PI = math.PI; var atan2 = math.atan2; var sqrt = math.sqrt; var min = math.min; var max = math.max; var cos = math.cos; var sin = math.sin; var V = function(el, attrs, children) { // This allows using V() without the new keyword. if (!(this instanceof V)) { return V.apply(Object.create(V.prototype), arguments); } if (!el) return; if (V.isV(el)) { el = el.node; } attrs = attrs || {}; if (V.isString(el)) { if (el.toLowerCase() === 'svg') { // Create a new SVG canvas. el = V.createSvgDocument(); } else if (el[0] === '<') { // Create element from an SVG string. // Allows constructs of type: `document.appendChild(V('<rect></rect>').node)`. var svgDoc = V.createSvgDocument(el); // Note that `V()` might also return an array should the SVG string passed as // the first argument contain more than one root element. if (svgDoc.childNodes.length > 1) { // Map child nodes to `V`s. var arrayOfVels = []; var i, len; for (i = 0, len = svgDoc.childNodes.length; i < len; i++) { var childNode = svgDoc.childNodes[i]; arrayOfVels.push(new V(document.importNode(childNode, true))); } return arrayOfVels; } el = document.importNode(svgDoc.firstChild, true); } else { el = document.createElementNS(ns.svg, el); } V.ensureId(el); } this.node = el; this.setAttributes(attrs); if (children) { this.append(children); } return this; }; var VPrototype = V.prototype; Object.defineProperty(VPrototype, 'id', { enumerable: true, get: function() { return this.node.id; }, set: function(id) { this.node.id = id; } }); /** * @param {SVGGElement} toElem * @returns {SVGMatrix} */ VPrototype.getTransformToElement = function(target) { var node = this.node; if (V.isSVGGraphicsElement(target) && V.isSVGGraphicsElement(node)) { var targetCTM = V.toNode(target).getScreenCTM(); var nodeCTM = node.getScreenCTM(); if (targetCTM && nodeCTM) { return targetCTM.inverse().multiply(nodeCTM); } } // Could not get actual transformation matrix return V.createSVGMatrix(); }; /** * @param {SVGMatrix} matrix * @param {Object=} opt * @returns {Vectorizer|SVGMatrix} Setter / Getter */ VPrototype.transform = function(matrix, opt) { var node = this.node; if (V.isUndefined(matrix)) { return V.transformStringToMatrix(this.attr('transform')); } if (opt && opt.absolute) { return this.attr('transform', V.matrixToTransformString(matrix)); } var svgTransform = V.createSVGTransform(matrix); node.transform.baseVal.appendItem(svgTransform); return this; }; VPrototype.translate = function(tx, ty, opt) { opt = opt || {}; ty = ty || 0; var transformAttr = this.attr('transform') || ''; var transform = V.parseTransformString(transformAttr); transformAttr = transform.value; // Is it a getter? if (V.isUndefined(tx)) { return transform.translate; } transformAttr = transformAttr.replace(/translate\([^)]*\)/g, '').trim(); var newTx = opt.absolute ? tx : transform.translate.tx + tx; var newTy = opt.absolute ? ty : transform.translate.ty + ty; var newTranslate = 'translate(' + newTx + ',' + newTy + ')'; // Note that `translate()` is always the first transformation. This is // usually the desired case. this.attr('transform', (newTranslate + ' ' + transformAttr).trim()); return this; }; VPrototype.rotate = function(angle, cx, cy, opt) { opt = opt || {}; var transformAttr = this.attr('transform') || ''; var transform = V.parseTransformString(transformAttr); transformAttr = transform.value; // Is it a getter? if (V.isUndefined(angle)) { return transform.rotate; } transformAttr = transformAttr.replace(/rotate\([^)]*\)/g, '').trim(); angle %= 360; var newAngle = opt.absolute ? angle : transform.rotate.angle + angle; var newOrigin = (cx !== undefined && cy !== undefined) ? ',' + cx + ',' + cy : ''; var newRotate = 'rotate(' + newAngle + newOrigin + ')'; this.attr('transform', (transformAttr + ' ' + newRotate).trim()); return this; }; // Note that `scale` as the only transformation does not combine with previous values. VPrototype.scale = function(sx, sy) { sy = V.isUndefined(sy) ? sx : sy; var transformAttr = this.attr('transform') || ''; var transform = V.parseTransformString(transformAttr); transformAttr = transform.value; // Is it a getter? if (V.isUndefined(sx)) { return transform.scale; } transformAttr = transformAttr.replace(/scale\([^)]*\)/g, '').trim(); var newScale = 'scale(' + sx + ',' + sy + ')'; this.attr('transform', (transformAttr + ' ' + newScale).trim()); return this; }; // Get SVGRect that contains coordinates and dimension of the real bounding box, // i.e. after transformations are applied. // If `target` is specified, bounding box will be computed relatively to `target` element. VPrototype.bbox = function(withoutTransformations, target) { var box; var node = this.node; var ownerSVGElement = node.ownerSVGElement; // If the element is not in the live DOM, it does not have a bounding box defined and // so fall back to 'zero' dimension element. if (!ownerSVGElement) { return new g.Rect(0, 0, 0, 0); } try { box = node.getBBox(); } catch (e) { // Fallback for IE. box = { x: node.clientLeft, y: node.clientTop, width: node.clientWidth, height: node.clientHeight }; } if (withoutTransformations) { return new g.Rect(box); } var matrix = this.getTransformToElement(target || ownerSVGElement); return V.transformRect(box, matrix); }; // Returns an SVGRect that contains coordinates and dimensions of the real bounding box, // i.e. after transformations are applied. // Fixes a browser implementation bug that returns incorrect bounding boxes for groups of svg elements. // Takes an (Object) `opt` argument (optional) with the following attributes: // (Object) `target` (optional): if not undefined, transform bounding boxes relative to `target`; if undefined, transform relative to this // (Boolean) `recursive` (optional): if true, recursively enter all groups and get a union of element bounding boxes (svg bbox fix); if false or undefined, return result of native function this.node.getBBox(); VPrototype.getBBox = function(opt) { var options = {}; var outputBBox; var node = this.node; var ownerSVGElement = node.ownerSVGElement; // If the element is not in the live DOM, it does not have a bounding box defined and // so fall back to 'zero' dimension element. // If the element is not an SVGGraphicsElement, we could not measure the bounding box either if (!ownerSVGElement || !V.isSVGGraphicsElement(node)) { return new g.Rect(0, 0, 0, 0); } if (opt) { if (opt.target) { // check if target exists options.target = V.toNode(opt.target); // works for V objects, jquery objects, and node objects } if (opt.recursive) { options.recursive = opt.recursive; } } if (!options.recursive) { try { outputBBox = node.getBBox(); } catch (e) { // Fallback for IE. outputBBox = { x: node.clientLeft, y: node.clientTop, width: node.clientWidth, height: node.clientHeight }; } if (!options.target) { // transform like this (that is, not at all) return new g.Rect(outputBBox); } else { // transform like target var matrix = this.getTransformToElement(options.target); return V.transformRect(outputBBox, matrix); } } else { // if we want to calculate the bbox recursively // browsers report correct bbox around svg elements (one that envelops the path lines tightly) // but some browsers fail to report the same bbox when the elements are in a group (returning a looser bbox that also includes control points, like node.getClientRect()) // this happens even if we wrap a single svg element into a group! // this option setting makes the function recursively enter all the groups from this and deeper, get bboxes of the elements inside, then return a union of those bboxes var children = this.children(); var n = children.length; if (n === 0) { return this.getBBox({ target: options.target, recursive: false }); } // recursion's initial pass-through setting: // recursive passes-through just keep the target as whatever was set up here during the initial pass-through if (!options.target) { // transform children/descendants like this (their parent/ancestor) options.target = this; } // else transform children/descendants like target for (var i = 0; i < n; i++) { var currentChild = children[i]; var childBBox; // if currentChild is not a group element, get its bbox with a nonrecursive call if (currentChild.children().length === 0) { childBBox = currentChild.getBBox({ target: options.target, recursive: false }); } else { // if currentChild is a group element (determined by checking the number of children), enter it with a recursive call childBBox = currentChild.getBBox({ target: options.target, recursive: true }); } if (!outputBBox) { // if this is the first iteration outputBBox = childBBox; } else { // make a new bounding box rectangle that contains this child's bounding box and previous bounding box outputBBox = outputBBox.union(childBBox); } } return outputBBox; } }; // Text() helpers function createTextPathNode(attrs, vel) { attrs || (attrs = {}); var textPathElement = V('textPath'); var d = attrs.d; if (d && attrs['xlink:href'] === undefined) { // If `opt.attrs` is a plain string, consider it to be directly the // SVG path data for the text to go along (this is a shortcut). // Otherwise if it is an object and contains the `d` property, then this is our path. // Wrap the text in the SVG <textPath> element that points // to a path defined by `opt.attrs` inside the `<defs>` element. var linkedPath = V('path').attr('d', d).appendTo(vel.defs()); textPathElement.attr('xlink:href', '#' + linkedPath.id); } if (V.isObject(attrs)) { // Set attributes on the `<textPath>`. The most important one // is the `xlink:href` that points to our newly created `<path/>` element in `<defs/>`. // Note that we also allow the following construct: // `t.text('my text', { textPath: { 'xlink:href': '#my-other-path' } })`. // In other words, one can completely skip the auto-creation of the path // and use any other arbitrary path that is in the document. textPathElement.attr(attrs); } return textPathElement.node; } function annotateTextLine(lineNode, lineAnnotations, opt) { opt || (opt = {}); var includeAnnotationIndices = opt.includeAnnotationIndices; var eol = opt.eol; var lineHeight = opt.lineHeight; var baseSize = opt.baseSize; var maxFontSize = 0; var fontMetrics = {}; var lastJ = lineAnnotations.length - 1; for (var j = 0; j <= lastJ; j++) { var annotation = lineAnnotations[j]; var fontSize = null; if (V.isObject(annotation)) { var annotationAttrs = annotation.attrs; var vTSpan = V('tspan', annotationAttrs); var tspanNode = vTSpan.node; var t = annotation.t; if (eol && j === lastJ) t += eol; tspanNode.textContent = t; // Per annotation className var annotationClass = annotationAttrs['class']; if (annotationClass) vTSpan.addClass(annotationClass); // If `opt.includeAnnotationIndices` is `true`, // set the list of indices of all the applied annotations // in the `annotations` attribute. This list is a comma // separated list of indices. if (includeAnnotationIndices) vTSpan.attr('annotations', annotation.annotations); // Check for max font size fontSize = parseFloat(annotationAttrs['font-size']); if (!isFinite(fontSize)) fontSize = baseSize; if (fontSize && fontSize > maxFontSize) maxFontSize = fontSize; } else { if (eol && j === lastJ) annotation += eol; tspanNode = document.createTextNode(annotation || ' '); if (baseSize && baseSize > maxFontSize) maxFontSize = baseSize; } lineNode.appendChild(tspanNode); } if (maxFontSize) fontMetrics.maxFontSize = maxFontSize; if (lineHeight) { fontMetrics.lineHeight = lineHeight; } else if (maxFontSize) { fontMetrics.lineHeight = (maxFontSize * 1.2); } return fontMetrics; } var emRegex = /em$/; function convertEmToPx(em, fontSize) { var numerical = parseFloat(em); if (emRegex.test(em)) return numerical * fontSize; return numerical; } function calculateDY(alignment, linesMetrics, baseSizePx, lineHeight) { if (!Array.isArray(linesMetrics)) return 0; var n = linesMetrics.length; if (!n) return 0; var lineMetrics = linesMetrics[0]; var flMaxFont = convertEmToPx(lineMetrics.maxFontSize, baseSizePx) || baseSizePx; var rLineHeights = 0; var lineHeightPx = convertEmToPx(lineHeight, baseSizePx); for (var i = 1; i < n; i++) { lineMetrics = linesMetrics[i]; var iLineHeight = convertEmToPx(lineMetrics.lineHeight, baseSizePx) || lineHeightPx; rLineHeights += iLineHeight; } var llMaxFont = convertEmToPx(lineMetrics.maxFontSize, baseSizePx) || baseSizePx; var dy; switch (alignment) { case 'middle': dy = (flMaxFont / 2) - (0.15 * llMaxFont) - (rLineHeights / 2); break; case 'bottom': dy = -(0.25 * llMaxFont) - rLineHeights; break; default: case 'top': dy = (0.8 * flMaxFont); break; } return dy; } VPrototype.text = function(content, opt) { if (content && typeof content !== 'string') throw new Error('Vectorizer: text() expects the first argument to be a string.'); // Replace all spaces with the Unicode No-break space (http://www.fileformat.info/info/unicode/char/a0/index.htm). // IE would otherwise collapse all spaces into one. content = V.sanitizeText(content); opt || (opt = {}); // Should we allow the text to be selected? var displayEmpty = opt.displayEmpty; // End of Line character var eol = opt.eol; // Text along path var textPath = opt.textPath; // Vertical shift var verticalAnchor = opt.textVerticalAnchor; var namedVerticalAnchor = (verticalAnchor === 'middle' || verticalAnchor === 'bottom' || verticalAnchor === 'top'); // Horizontal shift applied to all the lines but the first. var x = opt.x; if (x === undefined) x = this.attr('x') || 0; // Annotations var iai = opt.includeAnnotationIndices; var annotations = opt.annotations; if (annotations && !V.isArray(annotations)) annotations = [annotations]; // Shift all the <tspan> but first by one line (`1em`) var defaultLineHeight = opt.lineHeight; var autoLineHeight = (defaultLineHeight === 'auto'); var lineHeight = (autoLineHeight) ? '1.5em' : (defaultLineHeight || '1em'); // Clearing the element this.empty(); this.attr({ // Preserve spaces. In other words, we do not want consecutive spaces to get collapsed to one. 'xml:space': 'preserve', // An empty text gets rendered into the DOM in webkit-based browsers. // In order to unify this behaviour across all browsers // we rather hide the text element when it's empty. 'display': (content || displayEmpty) ? null : 'none' }); // Set default font-size if none var fontSize = parseFloat(this.attr('font-size')); if (!fontSize) { fontSize = 16; if (namedVerticalAnchor || annotations) this.attr('font-size', fontSize); } var doc = document; var containerNode; if (textPath) { // Now all the `<tspan>`s will be inside the `<textPath>`. if (typeof textPath === 'string') textPath = { d: textPath }; containerNode = createTextPathNode(textPath, this); } else { containerNode = doc.createDocumentFragment(); } var offset = 0; var lines = content.split('\n'); var linesMetrics = []; var annotatedY; for (var i = 0, lastI = lines.length - 1; i <= lastI; i++) { var dy = lineHeight; var lineClassName = 'v-line'; var lineNode = doc.createElementNS(ns.svg, 'tspan'); var line = lines[i]; var lineMetrics; if (line) { if (annotations) { // Find the *compacted* annotations for this line. var lineAnnotations = V.annotateString(line, annotations, { offset: -offset, includeAnnotationIndices: iai }); lineMetrics = annotateTextLine(lineNode, lineAnnotations, { includeAnnotationIndices: iai, eol: (i !== lastI && eol), lineHeight: (autoLineHeight) ? null : lineHeight, baseSize: fontSize }); // Get the line height based on the biggest font size in the annotations for this line. var iLineHeight = lineMetrics.lineHeight; if (iLineHeight && autoLineHeight && i !== 0) dy = iLineHeight; if (i === 0) annotatedY = lineMetrics.maxFontSize * 0.8; } else { if (eol && i !== lastI) line += eol; lineNode.textContent = line; } } else { // Make sure the textContent is never empty. If it is, add a dummy // character and make it invisible, making the following lines correctly // relatively positioned. `dy=1em` won't work with empty lines otherwise. lineNode.textContent = '-'; lineClassName += ' v-empty-line'; // 'opacity' needs to be specified with fill, stroke. Opacity without specification // is not applied in Firefox var lineNodeStyle = lineNode.style; lineNodeStyle.fillOpacity = 0; lineNodeStyle.strokeOpacity = 0; if (annotations) lineMetrics = {}; } if (lineMetrics) linesMetrics.push(lineMetrics); if (i > 0) lineNode.setAttribute('dy', dy); // Firefox requires 'x' to be set on the first line when inside a text path if (i > 0 || textPath) lineNode.setAttribute('x', x); lineNode.className.baseVal = lineClassName; containerNode.appendChild(lineNode); offset += line.length + 1; // + 1 = newline character. } // Y Alignment calculation if (namedVerticalAnchor) { if (annotations) { dy = calculateDY(verticalAnchor, linesMetrics, fontSize, lineHeight); } else if (verticalAnchor === 'top') { // A shortcut for top alignment. It does not depend on font-size nor line-height dy = '0.8em'; } else { var rh; // remaining height if (lastI > 0) { rh = parseFloat(lineHeight) || 1; rh *= lastI; if (!emRegex.test(lineHeight)) rh /= fontSize; } else { // Single-line text rh = 0; } switch (verticalAnchor) { case 'middle': dy = (0.3 - (rh / 2)) + 'em'; break; case 'bottom': dy = (-rh - 0.3) + 'em'; break; } } } else { if (verticalAnchor === 0) { dy = '0em'; } else if (verticalAnchor) { dy = verticalAnchor; } else { // No vertical anchor is defined dy = 0; // Backwards compatibility - we change the `y` attribute instead of `dy`. if (this.attr('y') === null) this.attr('y', annotatedY || '0.8em'); } } containerNode.firstChild.setAttribute('dy', dy); // Appending lines to the element. this.append(containerNode); return this; }; /** * @public * @param {string} name * @returns {Vectorizer} */ VPrototype.removeAttr = function(name) { var qualifiedName = V.qualifyAttr(name); var el = this.node; if (qualifiedName.ns) { if (el.hasAttributeNS(qualifiedName.ns, qualifiedName.local)) { el.removeAttributeNS(qualifiedName.ns, qualifiedName.local); } } else if (el.hasAttribute(name)) { el.removeAttribute(name); } return this; }; VPrototype.attr = function(name, value) { if (V.isUndefined(name)) { // Return all attributes. var attributes = this.node.attributes; var attrs = {}; for (var i = 0; i < attributes.length; i++) { attrs[attributes[i].name] = attributes[i].value; } return attrs; } if (V.isString(name) && V.isUndefined(value)) { return this.node.getAttribute(name); } if (typeof name === 'object') { for (var attrName in name) { if (name.hasOwnProperty(attrName)) { this.setAttribute(attrName, name[attrName]); } } } else { this.setAttribute(name, value); } return this; }; VPrototype.normalizePath = function() { var tagName = this.tagName(); if (tagName === 'PATH') { this.attr('d', V.normalizePathData(this.attr('d'))); } return this; }; VPrototype.remove = function() { if (this.node.parentNode) { this.node.parentNode.removeChild(this.node); } return this; }; VPrototype.empty = function() { while (this.node.firstChild) { this.node.removeChild(this.node.firstChild); } return this; }; /** * @private * @param {object} attrs * @returns {Vectorizer} */ VPrototype.setAttributes = function(attrs) { for (var key in attrs) { if (attrs.hasOwnProperty(key)) { this.setAttribute(key, attrs[key]); } } return this; }; VPrototype.append = function(els) { if (!V.isArray(els)) { els = [els]; } for (var i = 0, len = els.length; i < len; i++) { this.node.appendChild(V.toNode(els[i])); // lgtm [js/xss-through-dom] } return this; }; VPrototype.prepend = function(els) { var child = this.node.firstChild; return child ? V(child).before(els) : this.append(els); }; VPrototype.before = function(els) { var node = this.node; var parent = node.parentNode; if (parent) { if (!V.isArray(els)) { els = [els]; } for (var i = 0, len = els.length; i < len; i++) { parent.insertBefore(V.toNode(els[i]), node); } } return this; }; VPrototype.appendTo = function(node) { V.toNode(node).appendChild(this.node); // lgtm [js/xss-through-dom] return this; }; VPrototype.svg = function() { return this.node instanceof window.SVGSVGElement ? this : V(this.node.ownerSVGElement); }; VPrototype.tagName = function() { return this.node.tagName.toUpperCase(); }; VPrototype.defs = function() { var context = this.svg() || this; var defsNode = context.node.getElementsByTagName('defs')[0]; if (defsNode) return V(defsNode); return V('defs').appendTo(context); }; VPrototype.clone = function() { var clone = V(this.node.cloneNode(true/* deep */)); // Note that clone inherits also ID. Therefore, we need to change it here. clone.node.id = V.uniqueId(); return clone; }; VPrototype.findOne = function(selector) { var found = this.node.querySelector(selector); return found ? V(found) : undefined; }; VPrototype.find = function(selector) { var vels = []; var nodes = this.node.querySelectorAll(selector); if (nodes) { // Map DOM elements to `V`s. for (var i = 0; i < nodes.length; i++) { vels.push(V(nodes[i])); } } return vels; }; // Returns an array of V elements made from children of this.node. VPrototype.children = function() { var children = this.node.childNodes; var outputArray = []; for (var i = 0; i < children.length; i++) { var currentChild = children[i]; if (currentChild.nodeType === 1) { outputArray.push(V(children[i])); } } return outputArray; }; // Returns the V element from parentNode of this.node. VPrototype.parent = function() { return V(this.node.parentNode) || null; }, // Find an index of an element inside its container. VPrototype.index = function() { var index = 0; var node = this.node.previousSibling; while (node) { // nodeType 1 for ELEMENT_NODE if (node.nodeType === 1) index++; node = node.previousSibling; } return index; }; VPrototype.findParentByClass = function(className, terminator) { var ownerSVGElement = this.node.ownerSVGElement; var node = this.node.parentNode; while (node && node !== terminator && node !== ownerSVGElement) { var vel = V(node); if (vel.hasClass(className)) { return vel; } node = node.parentNode; } return null; }; // https://jsperf.com/get-common-parent VPrototype.contains = function(el) { var a = this.node; var b = V.toNode(el); var bup = b && b.parentNode; return (a === bup) || !!(bup && bup.nodeType === 1 && (a.compareDocumentPosition(bup) & 16)); }; // Convert global point into the coordinate space of this element. VPrototype.toLocalPoint = function(x, y) { var svg = this.svg().node; var p = svg.createSVGPoint(); p.x = x; p.y = y; try { var globalPoint = p.matrixTransform(svg.getScreenCTM().inverse()); var globalToLocalMatrix = this.getTransformToElement(svg).inverse(); } catch (e) { // IE9 throws an exception in odd cases. (`Unexpected call to method or property access`) // We have to make do with the original coordianates. return p; } return globalPoint.matrixTransform(globalToLocalMatrix); }; VPrototype.translateCenterToPoint = function(p) { var bbox = this.getBBox({ target: this.svg() }); var center = bbox.center(); this.translate(p.x - center.x, p.y - center.y); return this; }; // Efficiently auto-orient an element. This basically implements the orient=auto attribute // of markers. The easiest way of understanding on what this does is to imagine the element is an // arrowhead. Calling this method on the arrowhead makes it point to the `position` point while // being auto-oriented (properly rotated) towards the `reference` point. // `target` is the element relative to which the transformations are applied. Usually a viewport. VPrototype.translateAndAutoOrient = function(position, reference, target) { position = new g.Point(position); reference = new g.Point(reference); target || (target = this.svg()); // Clean-up previously set transformations except the scale. If we didn't clean up the // previous transformations then they'd add up with the old ones. Scale is an exception as // it doesn't add up, consider: `this.scale(2).scale(2).scale(2)`. The result is that the // element is scaled by the factor 2, not 8. var scale = this.scale(); this.attr('transform', ''); var bbox = this.getBBox({ target: target }).scale(scale.sx, scale.sy); // 1. Translate to origin. var translateToOrigin = V.createSVGTransform(); translateToOrigin.setTranslate(-bbox.x - bbox.width / 2, -bbox.y - bbox.height / 2); // 2. Rotate around origin. var rotateAroundOrigin = V.createSVGTransform(); var angle = position.angleBetween(reference, position.clone().offset(1, 0)); if (angle) rotateAroundOrigin.setRotate(angle, 0, 0); // 3. Translate to the `position` + the offset (half my width) towards the `reference` point. var translateFromOrigin = V.createSVGTransform(); var finalPosition = position.clone().move(reference, bbox.width / 2); translateFromOrigin.setTranslate(2 * position.x - finalPosition.x, 2 * position.y - finalPosition.y); // 4. Get the current transformation matrix of this node var ctm = this.getTransformToElement(target); // 5. Apply transformations and the scale var transform = V.createSVGTransform(); transform.setMatrix( translateFromOrigin.matrix.multiply( rotateAroundOrigin.matrix.multiply( translateToOrigin.matrix.multiply( ctm.scale(scale.sx, scale.sy))))); this.attr('transform', V.matrixToTransformString(transform.matrix)); return this; }; VPrototype.animateAlongPath = function(attrs, path) { path = V.toNode(path); var id = V.ensureId(path); var animateMotion = V('animateMotion', attrs); var mpath = V('mpath', { 'xlink:href': '#' + id }); animateMotion.append(mpath); this.append(animateMotion); try { animateMotion.node.beginElement(); } catch (e) { // Fallback for IE 9. // Run the animation programmatically if FakeSmile (`http://leunen.me/fakesmile/`) present if (document.documentElement.getAttribute('smiling') === 'fake') { /* global getTargets:true, Animator:true, animators:true id2anim:true */ // Register the animation. (See `https://answers.launchpad.net/smil/+question/203333`) var animation = animateMotion.node; animation.animators = []; var animationID = animation.getAttribute('id'); if (animationID) id2anim[animationID] = animation; var targets = getTargets(animation); for (var i = 0, len = targets.length; i < len; i++) { var target = targets[i]; var animator = new Animator(animation, target, i); animators.push(animator); animation.animators[i] = animator; animator.register(); } } } return this; }; VPrototype.hasClass = function(className) { return new RegExp('(\\s|^)' + className + '(\\s|$)').test(this.node.getAttribute('class')); }; VPrototype.addClass = function(className) { if (className && !this.hasClass(className)) { var prevClasses = this.node.getAttribute('class') || ''; this.node.setAttribute('class', (prevClasses + ' ' + className).trim()); } return this; }; VPrototype.removeClass = function(className) { if (className && this.hasClass(className)) { var newClasses = this.node.getAttribute('class').replace(new RegExp('(\\s|^)' + className + '(\\s|$)', 'g'), '$2'); this.node.setAttribute('class', newClasses); } return this; }; VPrototype.toggleClass = function(className, toAdd) { var toRemove = V.isUndefined(toAdd) ? this.hasClass(className) : !toAdd; if (toRemove) { this.removeClass(className); } else { this.addClass(className); } return this; }; // Interpolate path by discrete points. The precision of the sampling // is controlled by `interval`. In other words, `sample()` will generate // a point on the path starting at the beginning of the path going to the end // every `interval` pixels. // The sampler can be very useful for e.g. finding intersection between two // paths (finding the two closest points from two samples). VPrototype.sample = function(interval) { interval = interval || 1; var node = this.node; var length = node.getTotalLength(); var samples = []; var distance = 0; var sample; while (distance < length) { sample = node.getPointAtLength(distance); samples.push({ x: sample.x, y: sample.y, distance: distance }); distance += interval; } return samples; }; VPrototype.convertToPath = function() { var path = V('path'); path.attr(this.attr()); var d = this.convertToPathData(); if (d) { path.attr('d', d); } return path; }; VPrototype.convertToPathData = function() { var tagName = this.tagName(); switch (tagName) { case 'PATH': return this.attr('d'); case 'LINE': return V.convertLineToPathData(this.node); case 'POLYGON': return V.convertPolygonToPathData(this.node); case 'POLYLINE': return V.convertPolylineToPathData(this.node); case 'ELLIPSE': return V.convertEllipseToPathData(this.node); case 'CIRCLE': return V.convertCircleToPathData(this.node); case 'RECT': return V.convertRectToPathData(this.node); } throw new Error(tagName + ' cannot be converted to PATH.'); }; V.prototype.toGeometryShape = function() { var x, y, width, height, cx, cy, r, rx, ry, points, d, x1, x2, y1, y2; switch (this.tagName()) { case 'RECT': x = parseFloat(this.attr('x')) || 0; y = parseFloat(this.attr('y')) || 0; width = parseFloat(this.attr('width')) || 0; height = parseFloat(this.attr('height')) || 0; return new g.Rect(x, y, width, height); case 'CIRCLE': cx = parseFloat(this.attr('cx')) || 0; cy = parseFloat(this.attr('cy')) || 0; r = parseFloat(this.attr('r')) || 0; return new g.Ellipse({ x: cx, y: cy }, r, r); case 'ELLIPSE': cx = parseFloat(this.attr('cx')) || 0; cy = parseFloat(this.attr('cy')) || 0; rx = parseFloat(this.attr('rx')) || 0; ry = parseFloat(this.attr('ry')) || 0; return new g.Ellipse({ x: cx, y: cy }, rx, ry); case 'POLYLINE': points = V.getPointsFromSvgNode(this); return new g.Polyline(points); case 'POLYGON': points = V.getPointsFromSvgNode(this); if (points.length > 1) points.push(points[0]); return new g.Polyline(points); case 'PATH': d = this.attr('d'); if (!g.Path.isDataSupported(d)) d = V.normalizePathData(d); return new g.Path(d); case 'LINE': x1 = parseFloat(this.attr('x1')) || 0; y1 = parseFloat(this.attr('y1')) || 0; x2 = parseFloat(this.attr('x2')) || 0; y2 = parseFloat(this.attr('y2')) || 0; return new g.Line({ x: x1, y: y1 }, { x: x2, y: y2 }); } // Anything else is a rectangle return this.getBBox(); }; // Find the intersection of a line starting in the center // of the SVG `node` ending in the point `ref`. // `target` is an SVG element to which `node`s transformations are relative to. // Note that `ref` point must be in the coordinate system of the `target` for this function to work properly. // Returns a point in the `target` coordinate system (the same system as `ref` is in) if // an intersection is found. Returns `undefined` otherwise. VPrototype.findIntersection = function(ref, target) { var svg = this.svg().node; target = target || svg; var bbox = this.getBBox({ target: target }); var center = bbox.center(); if (!bbox.intersectionWithLineFromCenterToPoint(ref)) return undefined; var spot; var tagName = this.tagName(); // Little speed up optimization for `<rect>` element. We do not do conversion // to path element and sampling but directly calculate the intersection through // a transformed geometrical rectangle. if (tagName === 'RECT') { var gRect = new g.Rect( parseFloat(this.attr('x') || 0), parseFloat(this.attr('y') || 0), parseFloat(this.attr('width')), parseFloat(this.attr('height')) ); // Get the rect transformation matrix with regards to the SVG document. var rectMatrix = this.getTransformToElement(target); // Decompose the matrix to find the rotation angle. var rectMatrixComponents = V.decomposeMatrix(rectMatrix); // Now we want to rotate the rectangle back so that we // can use `intersectionWithLineFromCenterToPoint()` passing the angle as the second argument. var resetRotation = svg.createSVGTransform(); resetRotation.setRotate(-rectMatrixComponents.rotation, center.x, center.y); var rect = V.transformRect(gRect, resetRotation.matrix.multiply(rectMatrix)); spot = (new g.Rect(rect)).intersectionWithLineFromCenterToPoint(ref, rectMatrixComponents.rotation); } else if (tagName === 'PATH' || tagName === 'POLYGON' || tagName === 'POLYLINE' || tagName === 'CIRCLE' || tagName === 'ELLIPSE') { var pathNode = (tagName === 'PATH') ? this : this.convertToPath(); var samples = pathNode.sample(); var minDistance = Infinity; var closestSamples = []; var i, sample, gp, centerDistance, refDistance, distance; for (i = 0; i < samples.length; i++) { sample = samples[i]; // Convert the sample point in the local coordinate system to the global coordinate system. gp = V.createSVGPoint(sample.x, sample.y); gp = gp.matrixTransform(this.getTransformToElement(target)); sample = new g.Point(gp); centerDistance = sample.distance(center); // Penalize a higher distance to the reference point by 10%. // This gives better results. This is due to // inaccuracies introduced by rounding errors and getPointAtLength() returns. refDistance = sample.distance(ref) * 1.1; distance = centerDistance + refDistance; if (distance < minDistance) { minDistance = distance; closestSamples = [{ sample: sample, refDistance: refDistance }]; } else if (distance < minDistance + 1) { closestSamples.push({ sample: sample, refDistance: refDistance }); } } closestSamples.sort(function(a, b) { return a.refDistance - b.refDistance; }); if (closestSamples[0]) { spot = closestSamples[0].sample; } } return spot; }; /** * @private * @param {string} name * @param {string} value * @returns {Vectorizer} */ VPrototype.setAttribute = function(name, value) { var el = this.node; if (value === null) { this.removeAttr(name); return this; } var qualifiedName = V.qualifyAttr(name); if (qualifiedName.ns) { // Attribute names can be namespaced. E.g. `image` elements // have a `xlink:href` attribute to set the source of the image. el.setAttributeNS(qualifiedName.ns, name, value); } else if (name === 'id') { el.id = value; } else { el.setAttribute(name, value); } return this; }; // Create an SVG document element. // If `content` is passed, it will be used as the SVG content of the `<svg>` root element. V.createSvgDocument = function(content) { if (content) { const XMLString = `<svg xmlns="${ns.svg}" xmlns:xlink="${ns.xlink}" version="${SVGVersion}">${content}</svg>`; const { documentElement } = V.parseXML(XMLString, { async: false }); return documentElement; } const svg = document.createElementNS(ns.svg, 'svg'); svg.setAttributeNS(ns.xmlns, 'xmlns:xlink', ns.xlink); svg.setAttribute('version', SVGVersion); return svg; }; V.createSVGStyle = function(stylesheet) { const { node } = V('style', { type: 'text/css' }, [ V.createCDATASection(stylesheet) ]); return node; }, V.createCDATASection = function(data = '') { const xml = document.implementation.createDocument(null, 'xml', null); return xml.createCDATASection(data); }; V.idCounter = 0; // A function returning a unique identifier for this client session with every call. V.uniqueId = function() { return 'v-' + (++V.idCounter); }; V.toNode = function(el) { return V.isV(el) ? el.node : (el.nodeName && el || el[0]); }; V.ensureId = function(node) { node = V.toNode(node); return node.id || (node.id = V.uniqueId()); }; // Replace all spaces with the Unicode No-break space (http://www.fileformat.info/info/unicode/char/a0/index.htm). // IE would otherwise collapse all spaces into one. This is used in the text() method but it is // also exposed so that the programmer can use it in case he needs to. This is useful e.g. in tests // when you want to compare the actual DOM text content without having to add the unicode character in // the place of all spaces. V.sanitizeText = function(text) { return (text || '').replace(/ /g, '\u00A0'); }; V.isUndefined = function(value) { return typeof value === 'undefined'; }; V.isString = function(value) { return typeof value === 'string'; }; V.isObject = function(value) { return value && (typeof value === 'object'); }; V.isArray = Array.isArray; V.parseXML = function(data, opt) { opt = opt || {}; var xml; try { var parser = new DOMParser(); if (!V.isUndefined(opt.async)) { parser.async = opt.async; } xml = parser.parseFromString(data, 'text/xml'); } catch (error) { xml = undefined; } if (!xml || xml.getElementsByTagName('parsererror').length) { throw new Error('Invalid XML: ' + data); } return xml; }; /** * @param {string} name * @returns {{ns: string|null, local: string}} namespace and attribute name */ V.qualifyAttr = function(name) { if (name.indexOf(':') !== -1) { var combinedKey = name.split(':'); return { ns: ns[combinedKey[0]], local: combinedKey[1] }; } return { ns: null, local: name }; }; V.transformRegex = /(\w+)\(([^,)]+),?([^)]+)?\)/gi; V.transformSeparatorRegex = /[ ,]+/; V.transformationListRegex = /^(\w+)\((.*)\)/; V.transformStringToMatrix = function(transform) { var transformationMatrix = V.createSVGMatrix(); var matches = transform && transform.match(V.transformRegex); if (!matches) { return transformationMatrix; } for (var i = 0, n = matches.length; i < n; i++) { var transformationString = matches[i]; var transformationMatch = transformationString.match(V.transformationListRegex); if (transformationMatch) { var sx, sy, tx, ty, angle; var ctm = V.createSVGMatrix(); var args = transformationMatch[2].split(V.transformSeparatorRegex); switch (transformationMatch[1].toLowerCase()) { case 'scale': sx = parseFloat(args[0]); sy = (args[1] === undefined) ? sx : parseFloat(args[1]); ctm = ctm.scaleNonUniform(sx, sy); break; case 'translate': tx = parseFloat(args[0]); ty = parseFloat(args[1]); ctm = ctm.translate(tx, ty); break; case 'rotate': angle = parseFloat(args[0]); tx = parseFloat(args[1]) || 0; ty = parseFloat