jointjs
Version:
JavaScript diagramming library
1,418 lines (1,136 loc) • 86.2 kB
JavaScript
// 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