vega-scenegraph
Version:
Vega scenegraph and renderers.
709 lines (600 loc) • 19.5 kB
JavaScript
import Renderer from './Renderer';
import {gradientRef, isGradient, patternPrefix} from './Gradient';
import marks from './marks/index';
import {ariaItemAttributes, ariaMarkAttributes} from './util/aria';
import {cssClass, domChild, domClear, domCreate} from './util/dom';
import {serializeXML} from './util/markup';
import {fontFamily, fontSize, lineHeight, textLines, textValue} from './util/text';
import {visit} from './util/visit';
import clip from './util/svg/clip';
import metadata from './util/svg/metadata';
import {rootAttributes, stylesAttr, stylesCss} from './util/svg/styles';
import {isArray} from 'vega-util';
const RootIndex = 0,
xmlns = 'http://www.w3.org/2000/xmlns/',
svgns = metadata.xmlns;
export default class SVGRenderer extends Renderer {
constructor(loader) {
super(loader);
this._dirtyID = 0;
this._dirty = [];
this._svg = null;
this._root = null;
this._defs = null;
}
/**
* Initialize a new SVGRenderer instance.
* @param {DOMElement} el - The containing DOM element for the display.
* @param {number} width - The coordinate width of the display, in pixels.
* @param {number} height - The coordinate height of the display, in pixels.
* @param {Array<number>} origin - The origin of the display, in pixels.
* The coordinate system will be translated to this point.
* @param {number} [scaleFactor=1] - Optional scaleFactor by which to multiply
* the width and height to determine the final pixel size.
* @return {SVGRenderer} - This renderer instance.
*/
initialize(el, width, height, origin, scaleFactor) {
// create the svg definitions cache
this._defs = {};
this._clearDefs();
if (el) {
this._svg = domChild(el, 0, 'svg', svgns);
this._svg.setAttributeNS(xmlns, 'xmlns', svgns);
this._svg.setAttributeNS(xmlns, 'xmlns:xlink', metadata['xmlns:xlink']);
this._svg.setAttribute('version', metadata['version']);
this._svg.setAttribute('class', 'marks');
domClear(el, 1);
// set the svg root group
this._root = domChild(this._svg, RootIndex, 'g', svgns);
setAttributes(this._root, rootAttributes);
// ensure no additional child elements
domClear(this._svg, RootIndex + 1);
}
// set background color if defined
this.background(this._bgcolor);
return super.initialize(el, width, height, origin, scaleFactor);
}
/**
* Get / set the background color.
*/
background(bgcolor) {
if (arguments.length && this._svg) {
this._svg.style.setProperty('background-color', bgcolor);
}
return super.background(...arguments);
}
/**
* Resize the display.
* @param {number} width - The new coordinate width of the display, in pixels.
* @param {number} height - The new coordinate height of the display, in pixels.
* @param {Array<number>} origin - The new origin of the display, in pixels.
* The coordinate system will be translated to this point.
* @param {number} [scaleFactor=1] - Optional scaleFactor by which to multiply
* the width and height to determine the final pixel size.
* @return {SVGRenderer} - This renderer instance;
*/
resize(width, height, origin, scaleFactor) {
super.resize(width, height, origin, scaleFactor);
if (this._svg) {
setAttributes(this._svg, {
width: this._width * this._scale,
height: this._height * this._scale,
viewBox: `0 0 ${this._width} ${this._height}`
});
this._root.setAttribute('transform', `translate(${this._origin})`);
}
this._dirty = [];
return this;
}
/**
* Returns the SVG element of the visualization.
* @return {DOMElement} - The SVG element.
*/
canvas() {
return this._svg;
}
/**
* Returns an SVG text string for the rendered content,
* or null if this renderer is currently headless.
*/
svg() {
const svg = this._svg,
bg = this._bgcolor;
if (!svg) return null;
let node;
if (bg) {
svg.removeAttribute('style');
node = domChild(svg, RootIndex, 'rect', svgns);
setAttributes(node, {width: this._width, height: this._height, fill: bg});
}
const text = serializeXML(svg);
if (bg) {
svg.removeChild(node);
this._svg.style.setProperty('background-color', bg);
}
return text;
}
/**
* Internal rendering method.
* @param {object} scene - The root mark of a scenegraph to render.
* @param {Array} markTypes - Array of the mark types to render.
* If undefined, render all mark types
*/
_render(scene, markTypes) {
// perform spot updates and re-render markup
if (this._dirtyCheck()) {
if (this._dirtyAll) this._clearDefs();
this.mark(this._root, scene, undefined, markTypes);
domClear(this._root, 1);
}
this.defs();
this._dirty = [];
++this._dirtyID;
return this;
}
// -- Manage rendering of items marked as dirty --
/**
* Flag a mark item as dirty.
* @param {Item} item - The mark item.
*/
dirty(item) {
if (item.dirty !== this._dirtyID) {
item.dirty = this._dirtyID;
this._dirty.push(item);
}
}
/**
* Check if a mark item is considered dirty.
* @param {Item} item - The mark item.
*/
isDirty(item) {
return this._dirtyAll
|| !item._svg
|| !item._svg.ownerSVGElement
|| item.dirty === this._dirtyID;
}
/**
* Internal method to check dirty status and, if possible,
* make targetted updates without a full rendering pass.
*/
_dirtyCheck() {
this._dirtyAll = true;
const items = this._dirty;
if (!items.length || !this._dirtyID) return true;
const id = ++this._dirtyID;
let item, mark, type, mdef, i, n, o;
for (i=0, n=items.length; i<n; ++i) {
item = items[i];
mark = item.mark;
if (mark.marktype !== type) {
// memoize mark instance lookup
type = mark.marktype;
mdef = marks[type];
}
if (mark.zdirty && mark.dirty !== id) {
this._dirtyAll = false;
dirtyParents(item, id);
mark.items.forEach(i => { i.dirty = id; });
}
if (mark.zdirty) continue; // handle in standard drawing pass
if (item.exit) { // EXIT
if (mdef.nested && mark.items.length) {
// if nested mark with remaining points, update instead
o = mark.items[0];
if (o._svg) this._update(mdef, o._svg, o);
} else if (item._svg) {
// otherwise remove from DOM
o = item._svg.parentNode;
if (o) o.removeChild(item._svg);
}
item._svg = null;
continue;
}
item = (mdef.nested ? mark.items[0] : item);
if (item._update === id) continue; // already visited
if (!item._svg || !item._svg.ownerSVGElement) {
// ENTER
this._dirtyAll = false;
dirtyParents(item, id);
} else {
// IN-PLACE UPDATE
this._update(mdef, item._svg, item);
}
item._update = id;
}
return !this._dirtyAll;
}
// -- Construct & maintain scenegraph to SVG mapping ---
/**
* Render a set of mark items.
* @param {SVGElement} el - The parent element in the SVG tree.
* @param {object} scene - The mark parent to render.
* @param {SVGElement} prev - The previous sibling in the SVG tree.
* @param {Array} markTypes - Array of the mark types to render.
* If undefined, render all mark types
*/
mark(el, scene, prev, markTypes) {
if (!this.isDirty(scene)) {
return scene._svg;
}
const svg = this._svg,
markType = scene.marktype,
mdef = marks[markType],
events = scene.interactive === false ? 'none' : null,
isGroup = mdef.tag === 'g';
const parent = bind(scene, el, prev, 'g', svg);
if (markType !== 'group' && markTypes != null && !markTypes.includes(markType)) {
domClear(parent, 0);
return scene._svg;
}
parent.setAttribute('class', cssClass(scene));
// apply aria attributes to parent container element
const aria = ariaMarkAttributes(scene);
for (const key in aria) setAttribute(parent, key, aria[key]);
if (!isGroup) {
setAttribute(parent, 'pointer-events', events);
}
setAttribute(parent, 'clip-path',
scene.clip ? clip(this, scene, scene.group) : null);
let sibling = null,
i = 0;
const process = item => {
const dirty = this.isDirty(item),
node = bind(item, parent, sibling, mdef.tag, svg);
if (dirty) {
this._update(mdef, node, item);
if (isGroup) recurse(this, node, item, markTypes);
}
sibling = node;
++i;
};
if (mdef.nested) {
if (scene.items.length) process(scene.items[0]);
} else {
visit(scene, process);
}
domClear(parent, i);
return parent;
}
/**
* Update the attributes of an SVG element for a mark item.
* @param {object} mdef - The mark definition object
* @param {SVGElement} el - The SVG element.
* @param {Item} item - The mark item.
*/
_update(mdef, el, item) {
// set dom element and values cache
// provides access to emit method
element = el;
values = el.__values__;
// apply aria-specific properties
ariaItemAttributes(emit, item);
// apply svg attributes
mdef.attr(emit, item, this);
// some marks need special treatment
const extra = mark_extras[mdef.type];
if (extra) extra.call(this, mdef, el, item);
// apply svg style attributes
// note: element state may have been modified by 'extra' method
if (element) this.style(element, item);
}
/**
* Update the presentation attributes of an SVG element for a mark item.
* @param {SVGElement} el - The SVG element.
* @param {Item} item - The mark item.
*/
style(el, item) {
if (item == null) return;
for (const prop in stylesAttr) {
let value = prop === 'font' ? fontFamily(item) : item[prop];
if (value === values[prop]) continue;
const name = stylesAttr[prop];
if (value == null) {
el.removeAttribute(name);
} else {
if (isGradient(value)) {
value = gradientRef(value, this._defs.gradient, href());
}
el.setAttribute(name, value + '');
}
values[prop] = value;
}
for (const prop in stylesCss) {
setStyle(el, stylesCss[prop], item[prop]);
}
}
/**
* Render SVG defs, as needed.
* Must be called *after* marks have been processed to ensure the
* collected state is current and accurate.
*/
defs() {
const svg = this._svg,
defs = this._defs;
let el = defs.el,
index = 0;
for (const id in defs.gradient) {
if (!el) defs.el = (el = domChild(svg, RootIndex + 1, 'defs', svgns));
index = updateGradient(el, defs.gradient[id], index);
}
for (const id in defs.clipping) {
if (!el) defs.el = (el = domChild(svg, RootIndex + 1, 'defs', svgns));
index = updateClipping(el, defs.clipping[id], index);
}
// clean-up
if (el) {
index === 0
? (svg.removeChild(el), defs.el = null)
: domClear(el, index);
}
}
/**
* Clear defs caches.
*/
_clearDefs() {
const def = this._defs;
def.gradient = {};
def.clipping = {};
}
}
// mark ancestor chain with a dirty id
function dirtyParents(item, id) {
for (; item && item.dirty !== id; item=item.mark.group) {
item.dirty = id;
if (item.mark && item.mark.dirty !== id) {
item.mark.dirty = id;
} else return;
}
}
// update gradient definitions
function updateGradient(el, grad, index) {
let i, n, stop;
if (grad.gradient === 'radial') {
// SVG radial gradients automatically transform to normalized bbox
// coordinates, in a way that is cumbersome to replicate in canvas.
// We wrap the radial gradient in a pattern element, allowing us to
// maintain a circular gradient that matches what canvas provides.
let pt = domChild(el, index++, 'pattern', svgns);
setAttributes(pt, {
id: patternPrefix + grad.id,
viewBox: '0,0,1,1',
width: '100%',
height: '100%',
preserveAspectRatio: 'xMidYMid slice'
});
pt = domChild(pt, 0, 'rect', svgns);
setAttributes(pt, {
width: 1,
height: 1,
fill: `url(${href()}#${grad.id})`
});
el = domChild(el, index++, 'radialGradient', svgns);
setAttributes(el, {
id: grad.id,
fx: grad.x1,
fy: grad.y1,
fr: grad.r1,
cx: grad.x2,
cy: grad.y2,
r: grad.r2
});
} else {
el = domChild(el, index++, 'linearGradient', svgns);
setAttributes(el, {
id: grad.id,
x1: grad.x1,
x2: grad.x2,
y1: grad.y1,
y2: grad.y2
});
}
for (i=0, n=grad.stops.length; i<n; ++i) {
stop = domChild(el, i, 'stop', svgns);
stop.setAttribute('offset', grad.stops[i].offset);
stop.setAttribute('stop-color', grad.stops[i].color);
}
domClear(el, i);
return index;
}
// update clipping path definitions
function updateClipping(el, clip, index) {
let mask;
el = domChild(el, index, 'clipPath', svgns);
el.setAttribute('id', clip.id);
if (clip.path) {
mask = domChild(el, 0, 'path', svgns);
mask.setAttribute('d', clip.path);
} else {
mask = domChild(el, 0, 'rect', svgns);
setAttributes(mask, {x: 0, y: 0, width: clip.width, height: clip.height});
}
domClear(el, 1);
return index + 1;
}
// Recursively process group contents.
function recurse(renderer, el, group, markTypes) {
// child 'g' element is second to last among children (path, g, path)
// other children here are foreground and background path elements
el = el.lastChild.previousSibling;
let prev, idx = 0;
visit(group, item => {
prev = renderer.mark(el, item, prev, markTypes);
++idx;
});
// remove any extraneous DOM elements
domClear(el, 1 + idx);
}
// Bind a scenegraph item to an SVG DOM element.
// Create new SVG elements as needed.
function bind(item, el, sibling, tag, svg) {
let node = item._svg, doc;
// create a new dom node if needed
if (!node) {
doc = el.ownerDocument;
node = domCreate(doc, tag, svgns);
item._svg = node;
if (item.mark) {
node.__data__ = item;
node.__values__ = {fill: 'default'};
// if group, create background, content, and foreground elements
if (tag === 'g') {
const bg = domCreate(doc, 'path', svgns);
node.appendChild(bg);
bg.__data__ = item;
const cg = domCreate(doc, 'g', svgns);
node.appendChild(cg);
cg.__data__ = item;
const fg = domCreate(doc, 'path', svgns);
node.appendChild(fg);
fg.__data__ = item;
fg.__values__ = {fill: 'default'};
}
}
}
// (re-)insert if (a) not contained in SVG or (b) sibling order has changed
if (node.ownerSVGElement !== svg || siblingCheck(node, sibling)) {
el.insertBefore(node, sibling ? sibling.nextSibling : el.firstChild);
}
return node;
}
// check if two nodes are ordered siblings
function siblingCheck(node, sibling) {
return node.parentNode
&& node.parentNode.childNodes.length > 1
&& node.previousSibling != sibling; // treat null/undefined the same
}
// -- Set attributes & styles on SVG elements ---
let element = null, // temp var for current SVG element
values = null; // temp var for current values hash
// Extra configuration for certain mark types
const mark_extras = {
group(mdef, el, item) {
const fg = element = el.childNodes[2];
values = fg.__values__;
mdef.foreground(emit, item, this);
values = el.__values__; // use parent's values hash
element = el.childNodes[1];
mdef.content(emit, item, this);
const bg = element = el.childNodes[0];
mdef.background(emit, item, this);
const value = item.mark.interactive === false ? 'none' : null;
if (value !== values.events) {
setAttribute(fg, 'pointer-events', value);
setAttribute(bg, 'pointer-events', value);
values.events = value;
}
if (item.strokeForeground && item.stroke) {
const fill = item.fill;
setAttribute(fg, 'display', null);
// set style of background
this.style(bg, item);
setAttribute(bg, 'stroke', null);
// set style of foreground
if (fill) item.fill = null;
values = fg.__values__;
this.style(fg, item);
if (fill) item.fill = fill;
// leave element null to prevent downstream styling
element = null;
} else {
// ensure foreground is ignored
setAttribute(fg, 'display', 'none');
}
},
image(mdef, el, item) {
if (item.smooth === false) {
setStyle(el, 'image-rendering', 'optimizeSpeed');
setStyle(el, 'image-rendering', 'pixelated');
} else {
setStyle(el, 'image-rendering', null);
}
},
text(mdef, el, item) {
const tl = textLines(item);
let key, value, doc, lh;
if (isArray(tl)) {
// multi-line text
value = tl.map(_ => textValue(item, _));
key = value.join('\n'); // content cache key
if (key !== values.text) {
domClear(el, 0);
doc = el.ownerDocument;
lh = lineHeight(item);
value.forEach((t, i) => {
const ts = domCreate(doc, 'tspan', svgns);
ts.__data__ = item; // data binding
ts.textContent = t;
if (i) {
ts.setAttribute('x', 0);
ts.setAttribute('dy', lh);
}
el.appendChild(ts);
});
values.text = key;
}
} else {
// single-line text
value = textValue(item, tl);
if (value !== values.text) {
el.textContent = value;
values.text = value;
}
}
setAttribute(el, 'font-family', fontFamily(item));
setAttribute(el, 'font-size', fontSize(item) + 'px');
setAttribute(el, 'font-style', item.fontStyle);
setAttribute(el, 'font-variant', item.fontVariant);
setAttribute(el, 'font-weight', item.fontWeight);
}
};
function emit(name, value, ns) {
// early exit if value is unchanged
if (value === values[name]) return;
// use appropriate method given namespace (ns)
if (ns) {
setAttributeNS(element, name, value, ns);
} else {
setAttribute(element, name, value);
}
// note current value for future comparison
values[name] = value;
}
function setStyle(el, name, value) {
if (value !== values[name]) {
if (value == null) {
el.style.removeProperty(name);
} else {
el.style.setProperty(name, value + '');
}
values[name] = value;
}
}
function setAttributes(el, attrs) {
for (const key in attrs) {
setAttribute(el, key, attrs[key]);
}
}
function setAttribute(el, name, value) {
if (value != null) {
// if value is provided, update DOM attribute
el.setAttribute(name, value);
} else {
// else remove DOM attribute
el.removeAttribute(name);
}
}
function setAttributeNS(el, name, value, ns) {
if (value != null) {
// if value is provided, update DOM attribute
el.setAttributeNS(ns, name, value);
} else {
// else remove DOM attribute
el.removeAttributeNS(ns, name);
}
}
function href() {
let loc;
return typeof window === 'undefined' ? ''
: (loc = window.location).hash ? loc.href.slice(0, -loc.hash.length)
: loc.href;
}