@sky-foundry/two.js
Version:
A renderer agnostic two-dimensional drawing api for the web.
942 lines (693 loc) • 24 kB
JavaScript
(function(Two) {
// Localize variables
var mod = Two.Utils.mod, toFixed = Two.Utils.toFixed;
var _ = Two.Utils;
var svg = {
version: 1.1,
ns: 'http://www.w3.org/2000/svg',
xlink: 'http://www.w3.org/1999/xlink',
alignments: {
left: 'start',
center: 'middle',
right: 'end'
},
/**
* Create an svg namespaced element.
*/
createElement: function(name, attrs) {
var tag = name;
var elem = document.createElementNS(svg.ns, tag);
if (tag === 'svg') {
attrs = _.defaults(attrs || {}, {
version: svg.version
});
}
if (!_.isEmpty(attrs)) {
svg.setAttributes(elem, attrs);
}
return elem;
},
/**
* Add attributes from an svg element.
*/
setAttributes: function(elem, attrs) {
var keys = Object.keys(attrs);
for (var i = 0; i < keys.length; i++) {
if (/href/.test(keys[i])) {
elem.setAttributeNS(svg.xlink, keys[i], attrs[keys[i]]);
} else {
elem.setAttribute(keys[i], attrs[keys[i]]);
}
}
return this;
},
/**
* Remove attributes from an svg element.
*/
removeAttributes: function(elem, attrs) {
for (var key in attrs) {
elem.removeAttribute(key);
}
return this;
},
/**
* Turn a set of vertices into a string for the d property of a path
* element. It is imperative that the string collation is as fast as
* possible, because this call will be happening multiple times a
* second.
*/
toString: function(points, closed) {
var l = points.length,
last = l - 1,
d, // The elusive last Two.Commands.move point
string = '';
for (var i = 0; i < l; i++) {
var b = points[i];
var command;
var prev = closed ? mod(i - 1, l) : Math.max(i - 1, 0);
var next = closed ? mod(i + 1, l) : Math.min(i + 1, last);
var a = points[prev];
var c = points[next];
var vx, vy, ux, uy, ar, bl, br, cl;
var rx, ry, xAxisRotation, largeArcFlag, sweepFlag;
// Access x and y directly,
// bypassing the getter
var x = toFixed(b.x);
var y = toFixed(b.y);
switch (b.command) {
case Two.Commands.close:
command = Two.Commands.close;
break;
case Two.Commands.arc:
rx = b.rx;
ry = b.ry;
xAxisRotation = b.xAxisRotation;
largeArcFlag = b.largeArcFlag;
sweepFlag = b.sweepFlag;
command = Two.Commands.arc + ' ' + rx + ' ' + ry + ' '
+ xAxisRotation + ' ' + largeArcFlag + ' ' + sweepFlag + ' '
+ x + ' ' + y;
break;
case Two.Commands.curve:
ar = (a.controls && a.controls.right) || Two.Vector.zero;
bl = (b.controls && b.controls.left) || Two.Vector.zero;
if (a.relative) {
vx = toFixed((ar.x + a.x));
vy = toFixed((ar.y + a.y));
} else {
vx = toFixed(ar.x);
vy = toFixed(ar.y);
}
if (b.relative) {
ux = toFixed((bl.x + b.x));
uy = toFixed((bl.y + b.y));
} else {
ux = toFixed(bl.x);
uy = toFixed(bl.y);
}
command = ((i === 0) ? Two.Commands.move : Two.Commands.curve) +
' ' + vx + ' ' + vy + ' ' + ux + ' ' + uy + ' ' + x + ' ' + y;
break;
case Two.Commands.move:
d = b;
command = Two.Commands.move + ' ' + x + ' ' + y;
break;
default:
command = b.command + ' ' + x + ' ' + y;
}
// Add a final point and close it off
if (i >= last && closed) {
if (b.command === Two.Commands.curve) {
// Make sure we close to the most previous Two.Commands.move
c = d;
br = (b.controls && b.controls.right) || b;
cl = (c.controls && c.controls.left) || c;
if (b.relative) {
vx = toFixed((br.x + b.x));
vy = toFixed((br.y + b.y));
} else {
vx = toFixed(br.x);
vy = toFixed(br.y);
}
if (c.relative) {
ux = toFixed((cl.x + c.x));
uy = toFixed((cl.y + c.y));
} else {
ux = toFixed(cl.x);
uy = toFixed(cl.y);
}
x = toFixed(c.x);
y = toFixed(c.y);
command +=
' C ' + vx + ' ' + vy + ' ' + ux + ' ' + uy + ' ' + x + ' ' + y;
}
if (b.command !== Two.Commands.close) {
command += ' Z';
}
}
string += command + ' ';
}
return string;
},
getClip: function(shape) {
var clip = shape._renderer.clip;
if (!clip) {
var root = shape;
while (root.parent) {
root = root.parent;
}
clip = shape._renderer.clip = svg.createElement('clipPath');
root.defs.appendChild(clip);
}
return clip;
},
group: {
// TODO: Can speed up.
// TODO: How does this effect a f
appendChild: function(object) {
var elem = object._renderer.elem;
if (!elem) {
return;
}
var tag = elem.nodeName;
if (!tag || /(radial|linear)gradient/i.test(tag) || object._clip) {
return;
}
this.elem.appendChild(elem);
},
removeChild: function(object) {
var elem = object._renderer.elem;
if (!elem || elem.parentNode != this.elem) {
return;
}
var tag = elem.nodeName;
if (!tag) {
return;
}
// Defer subtractions while clipping.
if (object._clip) {
return;
}
this.elem.removeChild(elem);
},
orderChild: function(object) {
this.elem.appendChild(object._renderer.elem);
},
renderChild: function(child) {
svg[child._renderer.type].render.call(child, this);
},
render: function(domElement) {
this._update();
// Shortcut for hidden objects.
// Doesn't reset the flags, so changes are stored and
// applied once the object is visible again
if (this._opacity === 0 && !this._flagOpacity) {
return this;
}
if (!this._renderer.elem) {
this._renderer.elem = svg.createElement('g', {
id: this.id
});
domElement.appendChild(this._renderer.elem);
}
// _Update styles for the <g>
var flagMatrix = this._matrix.manual || this._flagMatrix;
var context = {
domElement: domElement,
elem: this._renderer.elem
};
if (flagMatrix) {
this._renderer.elem.setAttribute('transform', 'matrix(' + this._matrix.toString() + ')');
}
for (var i = 0; i < this.children.length; i++) {
var child = this.children[i];
svg[child._renderer.type].render.call(child, domElement);
}
if (this._flagOpacity) {
this._renderer.elem.setAttribute('opacity', this._opacity);
}
if (this._flagClassName) {
this._renderer.elem.setAttribute('class', this._className);
}
if (this._flagAdditions) {
this.additions.forEach(svg.group.appendChild, context);
}
if (this._flagSubtractions) {
this.subtractions.forEach(svg.group.removeChild, context);
}
if (this._flagOrder) {
this.children.forEach(svg.group.orderChild, context);
}
/**
* Commented two-way functionality of clips / masks with groups and
* polygons. Uncomment when this bug is fixed:
* https://code.google.com/p/chromium/issues/detail?id=370951
*/
// if (this._flagClip) {
// clip = svg.getClip(this);
// elem = this._renderer.elem;
// if (this._clip) {
// elem.removeAttribute('id');
// clip.setAttribute('id', this.id);
// clip.appendChild(elem);
// } else {
// clip.removeAttribute('id');
// elem.setAttribute('id', this.id);
// this.parent._renderer.elem.appendChild(elem); // TODO: should be insertBefore
// }
// }
if (this._flagMask) {
if (this._mask) {
this._renderer.elem.setAttribute('clip-path', 'url(#' + this._mask.id + ')');
} else {
this._renderer.elem.removeAttribute('clip-path');
}
}
return this.flagReset();
}
},
path: {
render: function(domElement) {
this._update();
// Shortcut for hidden objects.
// Doesn't reset the flags, so changes are stored and
// applied once the object is visible again
if (this._opacity === 0 && !this._flagOpacity) {
return this;
}
// Collect any attribute that needs to be changed here
var changed = {};
var flagMatrix = this._matrix.manual || this._flagMatrix;
if (flagMatrix) {
changed.transform = 'matrix(' + this._matrix.toString() + ')';
}
if (this._flagVertices) {
var vertices = svg.toString(this._renderer.vertices, this._closed);
changed.d = vertices;
}
if (this._fill && this._fill._renderer) {
this._fill._update();
svg[this._fill._renderer.type].render.call(this._fill, domElement, true);
}
if (this._flagFill) {
changed.fill = this._fill && this._fill.id
? 'url(#' + this._fill.id + ')' : this._fill;
}
if (this._stroke && this._stroke._renderer) {
this._stroke._update();
svg[this._stroke._renderer.type].render.call(this._stroke, domElement, true);
}
if (this._flagStroke) {
changed.stroke = this._stroke && this._stroke.id
? 'url(#' + this._stroke.id + ')' : this._stroke;
}
if (this._flagLinewidth) {
changed['stroke-width'] = this._linewidth;
}
if (this._flagOpacity) {
changed['stroke-opacity'] = this._opacity;
changed['fill-opacity'] = this._opacity;
}
if (this._flagClassName) {
changed['class'] = this._className;
}
if (this._flagVisible) {
changed.visibility = this._visible ? 'visible' : 'hidden';
}
if (this._flagCap) {
changed['stroke-linecap'] = this._cap;
}
if (this._flagJoin) {
changed['stroke-linejoin'] = this._join;
}
if (this._flagMiter) {
changed['stroke-miterlimit'] = this._miter;
}
if (this.dashes && this.dashes.length > 0) {
changed['stroke-dasharray'] = this.dashes.join(' ');
}
// If there is no attached DOM element yet,
// create it with all necessary attributes.
if (!this._renderer.elem) {
changed.id = this.id;
this._renderer.elem = svg.createElement('path', changed);
domElement.appendChild(this._renderer.elem);
// Otherwise apply all pending attributes
} else {
svg.setAttributes(this._renderer.elem, changed);
}
if (this._flagClip) {
var clip = svg.getClip(this);
var elem = this._renderer.elem;
if (this._clip) {
elem.removeAttribute('id');
clip.setAttribute('id', this.id);
clip.appendChild(elem);
} else {
clip.removeAttribute('id');
elem.setAttribute('id', this.id);
this.parent._renderer.elem.appendChild(elem); // TODO: should be insertBefore
}
}
/**
* Commented two-way functionality of clips / masks with groups and
* polygons. Uncomment when this bug is fixed:
* https://code.google.com/p/chromium/issues/detail?id=370951
*/
// if (this._flagMask) {
// if (this._mask) {
// elem.setAttribute('clip-path', 'url(#' + this._mask.id + ')');
// } else {
// elem.removeAttribute('clip-path');
// }
// }
return this.flagReset();
}
},
text: {
render: function(domElement) {
this._update();
var changed = {};
var flagMatrix = this._matrix.manual || this._flagMatrix;
if (flagMatrix) {
changed.transform = 'matrix(' + this._matrix.toString() + ')';
}
if (this._flagFamily) {
changed['font-family'] = this._family;
}
if (this._flagSize) {
changed['font-size'] = this._size;
}
if (this._flagLeading) {
changed['line-height'] = this._leading;
}
if (this._flagAlignment) {
changed['text-anchor'] = svg.alignments[this._alignment] || this._alignment;
}
if (this._flagBaseline) {
changed['alignment-baseline'] = changed['dominant-baseline'] = this._baseline;
}
if (this._flagStyle) {
changed['font-style'] = this._style;
}
if (this._flagWeight) {
changed['font-weight'] = this._weight;
}
if (this._flagDecoration) {
changed['text-decoration'] = this._decoration;
}
if (this._fill && this._fill._renderer) {
this._fill._update();
svg[this._fill._renderer.type].render.call(this._fill, domElement, true);
}
if (this._flagFill) {
changed.fill = this._fill && this._fill.id
? 'url(#' + this._fill.id + ')' : this._fill;
}
if (this._stroke && this._stroke._renderer) {
this._stroke._update();
svg[this._stroke._renderer.type].render.call(this._stroke, domElement, true);
}
if (this._flagStroke) {
changed.stroke = this._stroke && this._stroke.id
? 'url(#' + this._stroke.id + ')' : this._stroke;
}
if (this._flagLinewidth) {
changed['stroke-width'] = this._linewidth;
}
if (this._flagOpacity) {
changed.opacity = this._opacity;
}
if (this._flagClassName) {
changed['class'] = this._className;
}
if (this._flagVisible) {
changed.visibility = this._visible ? 'visible' : 'hidden';
}
if (this.dashes && this.dashes.length > 0) {
changed['stroke-dasharray'] = this.dashes.join(' ');
}
if (!this._renderer.elem) {
changed.id = this.id;
this._renderer.elem = svg.createElement('text', changed);
domElement.defs.appendChild(this._renderer.elem);
} else {
svg.setAttributes(this._renderer.elem, changed);
}
if (this._flagClip) {
var clip = svg.getClip(this);
var elem = this._renderer.elem;
if (this._clip) {
elem.removeAttribute('id');
clip.setAttribute('id', this.id);
clip.appendChild(elem);
} else {
clip.removeAttribute('id');
elem.setAttribute('id', this.id);
this.parent._renderer.elem.appendChild(elem); // TODO: should be insertBefore
}
}
if (this._flagValue) {
this._renderer.elem.textContent = this._value;
}
return this.flagReset();
}
},
'linear-gradient': {
render: function(domElement, silent) {
if (!silent) {
this._update();
}
var changed = {};
if (this._flagEndPoints) {
changed.x1 = this.left._x;
changed.y1 = this.left._y;
changed.x2 = this.right._x;
changed.y2 = this.right._y;
}
if (this._flagSpread) {
changed.spreadMethod = this._spread;
}
// If there is no attached DOM element yet,
// create it with all necessary attributes.
if (!this._renderer.elem) {
changed.id = this.id;
changed.gradientUnits = 'userSpaceOnUse';
this._renderer.elem = svg.createElement('linearGradient', changed);
domElement.defs.appendChild(this._renderer.elem);
// Otherwise apply all pending attributes
} else {
svg.setAttributes(this._renderer.elem, changed);
}
if (this._flagStops) {
var lengthChanged = this._renderer.elem.childNodes.length
!== this.stops.length;
if (lengthChanged) {
this._renderer.elem.childNodes.length = 0;
}
for (var i = 0; i < this.stops.length; i++) {
var stop = this.stops[i];
var attrs = {};
if (stop._flagOffset) {
attrs.offset = 100 * stop._offset + '%';
}
if (stop._flagColor) {
attrs['stop-color'] = stop._color;
}
if (stop._flagOpacity) {
attrs['stop-opacity'] = stop._opacity;
}
if (!stop._renderer.elem) {
stop._renderer.elem = svg.createElement('stop', attrs);
} else {
svg.setAttributes(stop._renderer.elem, attrs);
}
if (lengthChanged) {
this._renderer.elem.appendChild(stop._renderer.elem);
}
stop.flagReset();
}
}
return this.flagReset();
}
},
'radial-gradient': {
render: function(domElement, silent) {
if (!silent) {
this._update();
}
var changed = {};
if (this._flagCenter) {
changed.cx = this.center._x;
changed.cy = this.center._y;
}
if (this._flagFocal) {
changed.fx = this.focal._x;
changed.fy = this.focal._y;
}
if (this._flagRadius) {
changed.r = this._radius;
}
if (this._flagSpread) {
changed.spreadMethod = this._spread;
}
// If there is no attached DOM element yet,
// create it with all necessary attributes.
if (!this._renderer.elem) {
changed.id = this.id;
changed.gradientUnits = 'userSpaceOnUse';
this._renderer.elem = svg.createElement('radialGradient', changed);
domElement.defs.appendChild(this._renderer.elem);
// Otherwise apply all pending attributes
} else {
svg.setAttributes(this._renderer.elem, changed);
}
if (this._flagStops) {
var lengthChanged = this._renderer.elem.childNodes.length
!== this.stops.length;
if (lengthChanged) {
this._renderer.elem.childNodes.length = 0;
}
for (var i = 0; i < this.stops.length; i++) {
var stop = this.stops[i];
var attrs = {};
if (stop._flagOffset) {
attrs.offset = 100 * stop._offset + '%';
}
if (stop._flagColor) {
attrs['stop-color'] = stop._color;
}
if (stop._flagOpacity) {
attrs['stop-opacity'] = stop._opacity;
}
if (!stop._renderer.elem) {
stop._renderer.elem = svg.createElement('stop', attrs);
} else {
svg.setAttributes(stop._renderer.elem, attrs);
}
if (lengthChanged) {
this._renderer.elem.appendChild(stop._renderer.elem);
}
stop.flagReset();
}
}
return this.flagReset();
}
},
texture: {
render: function(domElement, silent) {
if (!silent) {
this._update();
}
var changed = {};
var styles = { x: 0, y: 0 };
var image = this.image;
if (this._flagLoaded && this.loaded) {
switch (image.nodeName.toLowerCase()) {
case 'canvas':
styles.href = styles['xlink:href'] = image.toDataURL('image/png');
break;
case 'img':
case 'image':
styles.href = styles['xlink:href'] = this.src;
break;
}
}
if (this._flagOffset || this._flagLoaded || this._flagScale) {
changed.x = this._offset.x;
changed.y = this._offset.y;
if (image) {
changed.x -= image.width / 2;
changed.y -= image.height / 2;
if (this._scale instanceof Two.Vector) {
changed.x *= this._scale.x;
changed.y *= this._scale.y;
} else {
changed.x *= this._scale;
changed.y *= this._scale;
}
}
if (changed.x > 0) {
changed.x *= - 1;
}
if (changed.y > 0) {
changed.y *= - 1;
}
}
if (this._flagScale || this._flagLoaded || this._flagRepeat) {
changed.width = 0;
changed.height = 0;
if (image) {
styles.width = changed.width = image.width;
styles.height = changed.height = image.height;
// TODO: Hack / Bandaid
switch (this._repeat) {
case 'no-repeat':
changed.width += 1;
changed.height += 1;
break;
}
if (this._scale instanceof Two.Vector) {
changed.width *= this._scale.x;
changed.height *= this._scale.y;
} else {
changed.width *= this._scale;
changed.height *= this._scale;
}
}
}
if (this._flagScale || this._flagLoaded) {
if (!this._renderer.image) {
this._renderer.image = svg.createElement('image', styles);
} else if (!_.isEmpty(styles)) {
svg.setAttributes(this._renderer.image, styles);
}
}
if (!this._renderer.elem) {
changed.id = this.id;
changed.patternUnits = 'userSpaceOnUse';
this._renderer.elem = svg.createElement('pattern', changed);
domElement.defs.appendChild(this._renderer.elem);
} else if (!_.isEmpty(changed)) {
svg.setAttributes(this._renderer.elem, changed);
}
if (this._renderer.elem && this._renderer.image && !this._renderer.appended) {
this._renderer.elem.appendChild(this._renderer.image);
this._renderer.appended = true;
}
return this.flagReset();
}
}
};
/**
* @class
*/
var Renderer = Two[Two.Types.svg] = function(params) {
this.domElement = params.domElement || svg.createElement('svg');
this.scene = new Two.Group();
this.scene.parent = this;
this.defs = svg.createElement('defs');
this.domElement.appendChild(this.defs);
this.domElement.defs = this.defs;
this.domElement.style.overflow = 'hidden';
};
_.extend(Renderer, {
Utils: svg
});
_.extend(Renderer.prototype, Two.Utils.Events, {
constructor: Renderer,
setSize: function(width, height) {
this.width = width;
this.height = height;
svg.setAttributes(this.domElement, {
width: width,
height: height
});
return this.trigger(Two.Events.resize, width, height);
},
render: function() {
svg.group.render.call(this.scene, this.domElement);
return this;
}
});
})((typeof global !== 'undefined' ? global : (this || window)).Two);