/* ** Zabbix ** Copyright (C) 2001-2023 Zabbix SIA ** ** This program is free software; you can redistribute it and/or modify ** it under the terms of the GNU General Public License as published by ** the Free Software Foundation; either version 2 of the License, or ** (at your option) any later version. ** ** This program is distributed in the hope that it will be useful, ** but WITHOUT ANY WARRANTY; without even the implied warranty of ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ** GNU General Public License for more details. ** ** You should have received a copy of the GNU General Public License ** along with this program; if not, write to the Free Software ** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. **/ /** * SVGCanvas class. * * Implements basic functionality needed to render SVG from JS. * * @param {object} options Canvas options. * @param {number} options.width Canvas width (width attribute of a SVG image). * @param {number} options.height Canvas height (height attribute of a SVG image). * @param {boolean} options.mask Masking option for textarea elements (@see SVGCanvas.prototype.createTextarea) * @param {boolean} shadowBuffer Shadow buffer (double buffering) support. If set to true, additional hidden * group element is created within SVG. */ function SVGCanvas(options, shadowBuffer) { this.options = options; this.id = 0; this.elements = []; this.textPadding = 5; this.maskColor = '#3d3d3d'; this.mask = false; if (typeof options.mask !== 'undefined') { this.mask = (options.mask === true); } if (typeof options.useViewBox !== 'boolean') { options.useViewBox = false; } this.buffer = null; var svg_options = options.useViewBox ? { 'viewBox': '0 0 ' + options.width + ' ' + options.height, 'style': 'max-width: ' + options.width + 'px; max-height: ' + options.height + 'px;', 'preserveAspectRatio': 'xMinYMin meet' } : { 'width': options.width, 'height': options.height }; this.root = this.createElement('svg', svg_options, null); if (shadowBuffer === true) { this.buffer = this.root.add('g', { class: 'shadow-buffer', style: 'visibility: hidden;' }); } } // Predefined namespaces for SVG as key => value SVGCanvas.NAMESPACES = { xlink: 'http://www.w3.org/1999/xlink' }; /** * Generate unique id. * Id is unique within page context. * * @return {number} Unique id. */ SVGCanvas.getUniqueId = function () { if (typeof SVGCanvas.uniqueId === 'undefined') { SVGCanvas.uniqueId = 0; } return SVGCanvas.uniqueId++; }; /** * Create new SVG element. * Additional workaround is added to implement textarea element as a text element with a set of tspan subelements. * * @param {string} type Element type (SVG tag). * @param {object} attributes Element attributes (SVG tag attributes) as key => value pairs. * @param {SVGElement} parent Parent element if any (or null if none). * @param {mixed} content Element textContent of a set of subelements. * * @return {SVGElement} Created element. */ SVGCanvas.prototype.createElement = function (type, attributes, parent, content) { var element; if(type.toLowerCase() === 'textarea') { var textarea = new SVGTextArea(this); element = textarea.create(attributes, parent, content); } else { element = new SVGElement(this, type, attributes, parent, content); this.elements.push(element); } return element; }; /** * Get elements by specified attributes. * * SVG elements with specified attributes are returned as array of SVGElement (if any). * * @return {array} Elements that match specified attributes. */ SVGCanvas.prototype.getElementsByAttributes = function (attributes) { var names = Object.keys(attributes), elements = this.elements.filter(function (item) { for (var i = 0; i < names.length; i++) { if (item.attributes[names[i]] !== attributes[names[i]]) { return false; } } return true; }); return elements; }; /** * Add element to the SVG root element (svg tag). * * @return {SVGElement} Created element. */ SVGCanvas.prototype.add = function (type, attributes, content) { return this.root.add(type, attributes, content); }; /** * Attach SVG element to the specified container in DOM. * * @param {object} container DOM node. */ SVGCanvas.prototype.render = function (container) { if (this.root.element.parentNode) { this.root.element.parentNode.removeChild(this.root.element); } container.appendChild(this.root.element); }; /** * Resize canvas. * * @param {number} width New width. * @param {number} height New height. * * @return {boolean} true if size is changed and false if size is the same as previous. */ SVGCanvas.prototype.resize = function (width, height) { if (this.options.width !== width || this.options.height !== height) { this.options.width = width; this.options.height = height; this.root.update({'width': width, 'height': height}); return true; } return false; }; /** * SVGTextArea class. * * Implements textarea (multiline text) for svg. * * @param {object} canvas Instance of SVGCanvas. * */ function SVGTextArea(canvas) { this.canvas = canvas; this.element = null; } /** * Parse text line and extract links as elements. * * @param {string} text Text line to be parsed. * * @return {mixed} Parsed text as {array} if links are present or as {string} if there are no links in text. */ SVGTextArea.parseLinks = function (text) { var index, offset = 0, link, parts = []; while ((index = text.search(/((ftp|file|https?):\/\/[^\s]+)/i)) !== -1) { if (offset !== index) { parts.push(text.substring(offset, index)); } text = text.substring(index); index = text.search(/\s/); if (index === -1) { index = text.length; } link = text.substring(0, index); text = text.substring(index); offset = 0; parts.push({ type: 'a', attributes: { href: link, onclick: 'window.location = ' + JSON.stringify(link) + '; return false;' // Workaround for Safari. }, content: link }); } if (text !== '') { if (parts.length !== 0) { parts.push(text); } else { parts = text; } } return parts; }; /** * Wrap text line to the specified width. * * @param {string} line Text line to be wrapped. * * @return {array} Wrapped line as {array} of strings. */ SVGTextArea.prototype.wrapLine = function (line) { if (this.canvas.buffer === null || typeof this.clip === 'undefined') { // No text wrapping without shadow buffer of clipping object. return [line]; } var max_width = this.clip.attributes.width, current; if (typeof max_width === 'undefined' && typeof this.clip.attributes.rx !== 'undefined') { max_width = parseInt(this.clip.attributes.rx * 2, 10); } max_width -= this.canvas.textPadding * 2; if (typeof this.canvas.wrapper === 'undefined') { this.canvas.wrapper = { text: this.canvas.buffer.add('text', this.attributes), node: document.createTextNode('') }; this.canvas.wrapper.text.element.appendChild(this.canvas.wrapper.node); } else { this.canvas.wrapper.text.update(this.attributes); } var text = this.canvas.wrapper.text.element, node = this.canvas.wrapper.node, size, wrapped = []; node.textContent = line; size = text.getBBox(); // Check length of the line in pixels. if (Math.ceil(size.width) > max_width) { var words = line.split(' '); current = []; while (words.length > 0) { current.push(words.shift()); node.textContent = current.join(' '); size = text.getBBox(); if (Math.ceil(size.width) > max_width) { if (current.length > 1) { words.unshift(current.pop()); wrapped.push(current.join(' ')); current = []; } else { // Word is too long to fit the line. wrapped.push(current.pop()); } } } if (current.length > 0) { wrapped.push(current.join(' ')); } } else { wrapped.push(line); } return wrapped; }; /** * Get horizontal offset (position in pixels) of text anchor. * * @return {numeric} Horizontal offset in pixels. */ SVGTextArea.prototype.getHorizontalOffset = function () { switch (this.anchor.horizontal) { case 'center': return Math.floor(this.width/2); case 'right': return this.width; } return 0; }; /** * Get text-anchor attribute value from horizontal anchor value. * * @return {string} Value of text-anchor attribute. */ SVGTextArea.prototype.getHorizontalAnchor = function() { var mapping = { left: 'start', center: 'middle', right: 'end' }; if (typeof mapping[this.anchor.horizontal] === 'string') { return mapping[this.anchor.horizontal]; } return mapping.left; }; /** * Parse content, get the lines, perform line wrapping and link parsing. * * @param {mixed} content Text contents or array of line objects. * @param {boolean} parse_links Set to true if link parsing should be performed. * * @return {numeric} Horizontal offset in pixels. */ SVGTextArea.prototype.parseContent = function(content, parse_links) { var skip = 0.9, anchor = this.getHorizontalAnchor(); this.lines = []; if (typeof content === 'string') { var items = []; content.split("\n").forEach(function (line) { items.push({ content: line, attributes: {} }); }); content = items; } content.forEach(function (line) { if (line.content.trim() !== '') { var content = line.content.replace(/[\r\n]/g, ''); this.wrapLine(content).forEach(function (wrapped) { if (parse_links === true) { wrapped = SVGTextArea.parseLinks(wrapped); } this.lines.push({ type: 'tspan', attributes: SVGElement.mergeAttributes({ x: this.offset, dy: skip + 'em', 'text-anchor': anchor }, line.attributes), content: wrapped }); skip = 1.2; }, this); } else { skip += 1.2; } }, this); }; /** * Align text position based on horizontal and vertical anchor values. */ SVGTextArea.prototype.alignToAnchor = function() { if (typeof this.anchor !== 'object') { this.anchor = { horizontal: 'left' }; } this.x -= this.getHorizontalOffset(); switch (this.anchor.vertical) { case 'middle': this.y -= Math.floor(this.height/2); break; case 'bottom': this.y -= this.height; break; } }; /** * Create clipping object to clip (and/or mask) text outside the specified shape. */ SVGTextArea.prototype.createClipping = function() { if (typeof this.clip !== 'undefined') { var offset = this.getHorizontalOffset(); // Clipping shape should be applied to the text. Clipping mode (clip or mask) depends on mask attribute. if (typeof this.clip.attributes.x !== 'undefined' && typeof this.clip.attributes.y !== 'undefined') { this.clip.attributes.x -= (this.x + offset); this.clip.attributes.y -= this.y; } else if (typeof this.clip.attributes.cx !== 'undefined' && typeof this.clip.attributes.cy !== 'undefined') { this.clip.attributes.cx -= (this.x + offset); this.clip.attributes.cy -= this.y; } var unique_id = SVGCanvas.getUniqueId(); if (this.canvas.mask) { this.clip.attributes.fill = '#ffffff'; this.element.add('mask', { id: 'mask-' + unique_id }, [{ type: 'rect', attributes: { x: -offset, y: 0, 'width': this.width, 'height': this.height, fill: this.canvas.maskColor } }, this.clip ]); this.text.element.setAttribute('mask', 'url(#mask-' + unique_id + ')'); } else { this.element.add('clipPath', { id: 'clip-' + unique_id }, [this.clip]); this.text.element.setAttribute('clip-path', 'url(#clip-' + unique_id + ')'); } } }; /** * Create new textarea element. * * Textarea element has poor support in supported browsers so following workaround is used. Textarea element is a text * element with a set of tspan subelements and additional logic for text background and masking / clipping. * * @param {string} type Element type (SVG tag). * @param {object} attributes Element attributes (SVG tag attributes). * @param {number} attributes.x Element position on x axis. * @param {number} attributes.y Element position on y axis. * @param {object} attributes.anchor Anchor used for text placement. * @param {string} attributes.anchor.horizontal Horizontal anchor used for text placement. * @param {string} attributes.anchor.vertical Vertical anchor used for text placement. * @param {object} attributes.background Attributes of rectangle placed behind text (text background). * @param {object} attributes.clip SVG element used for clipping or masking (depends on canvas mask option). * @param {SVGElement} parent Parent element if any (or null if none). * @param {mixed} content Element textContent of a set of subelements. * * @return {SVGElement} Created element. */ SVGTextArea.prototype.create = function(attributes, parent, content) { if (typeof content === 'string' && content.trim() === '') { return null; } if (Array.isArray(content)) { var i; for (i = 0; i < content.length; i++) { if (content[i].content.trim() !== '') { break; } } if (i === content.length) { return null; } } ['x', 'y', 'anchor', 'background', 'clip'].forEach(function (key) { this[key] = attributes[key]; }, this); this.offset = 0; this.element = this.canvas.createElement('g', {}, parent); var parse_links = attributes['parse-links'], size; ['x', 'y', 'anchor', 'background', 'clip', 'parse-links'].forEach(function (key) { delete attributes[key]; }); this.attributes = attributes; if (typeof this.background === 'object') { this.background = this.element.add('rect', this.background); this.x -= this.canvas.textPadding; this.y -= this.canvas.textPadding; this.offset = this.canvas.textPadding; } else { this.background = null; } this.parseContent(content, parse_links); this.text = this.element.add('text', attributes, this.lines); size = this.ZBX_getBBox(); this.width = Math.ceil(size.width); this.height = Math.ceil(size.height + size.y); // Workaround for EDGE for proper text height calculation. if (ED && this.lines.length > 0 && typeof attributes['font-size'] !== 'undefined' && parseInt(attributes['font-size']) > 16) { this.height = Math.ceil(this.lines.length * parseInt(attributes['font-size']) * 1.2); } this.alignToAnchor(); if (this.background !== null) { this.background.update({ width: this.width + (this.canvas.textPadding * 2), height: this.height + (this.canvas.textPadding * 2) }); } this.createClipping(); this.text.element.setAttribute('transform', 'translate(' + this.getHorizontalOffset() + ' ' + this.offset + ')'); this.element.element.setAttribute('transform', 'translate(' + this.x + ' ' + this.y + ')'); return this.element; }; /** * getBBox workaround for Firefox and probably also old versions of IE. * * Firefox is not able to get element dimensions using getBBox unless it is appended to the DOM. * The workaround creates a SVG element and appends it to the DOM to be able get element dimensions using the getBBox. * * Read more about this bug here https://bugzilla.mozilla.org/show_bug.cgi?id=612118 */ SVGTextArea.prototype.ZBX_getBBox = function() { try { return this.text.element.getBBox(); } catch (err) { var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), ret; svg.appendChild(this.text.element); document.body.appendChild(svg); ret = this.text.element.getBBox(); svg.parentNode.removeChild(svg); return ret; } }; /** * ImageCache class. * * Implements basic functionality needed to preload images, get image attributes and avoid flickering. */ function ImageCache() { this.lock = 0; this.images = {}; this.context = null; this.callback = null; this.queue = []; } /** * Invoke callback (if any), update image preload task queue. */ ImageCache.prototype.invokeCallback = function () { if (typeof this.callback === 'function') { this.callback.call(this.context); } // Preloads next image list if any. var task = this.queue.pop(); if (typeof task !== 'undefined') { this.preload(task.urls, task.callback, task.context); } }; /** * Handle image processing event (loaded or error). */ ImageCache.prototype.handleCallback = function () { this.lock--; // If all images are loaded (error is treated as "loaded"), invoke callback. if (this.lock === 0) { this.invokeCallback(); } }; /** * Callback for successful image load. * * @param {string} id Image id. * @param {object} image Loaded image. */ ImageCache.prototype.onImageLoaded = function (id, image) { this.images[id] = image; this.handleCallback(); }; /** * Callback for image loading errors. * * @param {string} id Image id. */ ImageCache.prototype.onImageError = function (id) { this.images[id] = null; this.handleCallback(); }; /** * Preload images. * * @param {object} urls Urls of images to be preloaded (urls are provided in key=>value format). * @param {function} callback Callback to be called when loading is finished. Can be null if no callback is needed. * @param {object} context Context of a callback. (@see first argument of Function.prototype.apply) * * @return {boolean} true if preloader started loading images and false if preloader is busy. */ ImageCache.prototype.preload = function (urls, callback, context) { // If preloader is busy, new preloading task is pushed to queue. if (this.lock !== 0) { this.queue.push({ 'urls': urls, 'callback': callback, 'context': context }); return false; } this.context = context; this.callback = callback; var images = 0; var object = this; Object.keys(urls).forEach(function (key) { var url = urls[key]; if (typeof url !== 'string') { object.onImageError.call(object, key); return; } if (typeof object.images[key] !== 'undefined') { // Image is pre-loaded already. return true; } var image = new Image(); image.onload = function () { object.onImageLoaded.call(object, key, image); }; image.onerror = function () { object.onImageError.call(object, key); }; image.src = url; object.lock++; images++; }); if (images === 0) { this.invokeCallback(); } return true; }; /** * SVGElement class. * * Implements basic functionality needed to create SVG elements. * * @see SVGCanvas.prototype.createElement * * @param {SVGCanvas} renderer SVGCanvas used to render elements. * @param {string} type Type of SVG element. * @param {object} attributes Element attributes (SVG tag attributes) as key => value pairs. * @param {SVGElement} parent Parent element if any (or null if none). * @param {mixed} content Element textContent of a set of subelements. */ function SVGElement(renderer, type, attributes, parent, content) { this.id = renderer.id++; this.type = type; this.attributes = attributes; this.content = content; this.canvas = renderer; this.parent = parent; this.items = []; this.element = null; this.invalid = false; if (type !== null) { this.create(); } } /** * Add clild SVG element. * * @see SVGCanvas.prototype.createElement * * @param {mixed} type Type of SVG element or array of objects containing type, attribute and content fields. * @param {object} attributes Element attributes (SVG tag attributes) as key => value pairs. * @param {mixed} content Element textContent of a set of subelements. * * @return {mixed} SVGElement created or array of SVGElement is type was Array. */ SVGElement.prototype.add = function (type, attributes, content) { // Multiple items to add. if (Array.isArray(type)) { var items = []; type.forEach(function (element) { if (typeof element !== 'object' || typeof element.type !== 'string') { throw 'Invalid element configuration!'; } items.push(this.add(element.type, element.attributes, element.content)); }, this); return items; } if (typeof attributes === 'undefined' || attributes === null) { attributes = {}; } var element = this.canvas.createElement(type, attributes, this, content); if (type.toLowerCase() !== 'textarea') { this.items.push(element); } return element; }; /** * Remove all children elements. * * @return {SVGElement} */ SVGElement.prototype.clear = function () { var items = this.items; items.forEach(function (item) { item.remove(); }); this.items = []; return this; }; /** * Update attributes of SVG element. * * @param {object} attributes New element attributes (SVG tag attributes) as key => value pairs. * * @return {SVGElement} */ SVGElement.prototype.update = function (attributes) { Object.keys(attributes).forEach(function (name) { var attribute = name.split(':'); if (attribute.length === 1) { this.element.setAttributeNS(null, name, attributes[name]); } else if (attribute.length === 2 && typeof SVGCanvas.NAMESPACES[attribute[0]] !== 'undefined') { this.element.setAttributeNS(SVGCanvas.NAMESPACES[attribute[0]], name, attributes[name]); } }, this); return this; }; /** * Moves element from one parent to another. * * @param {object} target New parent element. * * @return {SVGElement} */ SVGElement.prototype.moveTo = function (target) { this.parent.items = this.parent.items.filter(function (item) { return item.id !== this.id; }, this); this.parent = target; this.parent.items.push(this); target.element.appendChild(this.element); return this; }; /** * Mark element as invalid (flag used to force redraw of element). * * @return {SVGElement} */ SVGElement.prototype.invalidate = function () { this.invalid = true; return this; }; /** * Remove element from parent and from DOM. * * @return {SVGElement} */ SVGElement.prototype.remove = function () { this.clear(); if (this.element !== null) { // Workaround for IE as .remove() does not work in IE. if (typeof this.element.remove !== 'function') { if (typeof this.element.parentNode !== 'undefined') { this.element.parentNode.removeChild(this.element); } } else { this.element.remove(); } this.element = null; } if (this.parent !== null && typeof this.parent.items !== 'undefined') { this.parent.items = this.parent.items.filter(function (item) { return item.id !== this.id; }, this); } return this; }; /** * Replace existing DOM element with a new one. * * @param {object} target New DOM element. * * @return {SVGElement} */ SVGElement.prototype.replace = function (target) { if (this.element !== null && this.invalid === false) { this.element.parentNode.insertBefore(target.element, this.element); } this.remove(); Object.keys(target).forEach(function (key) { this[key] = target[key]; }, this); return this; }; /** * Create SVG DOM element. * * @return {object} DOM element. */ SVGElement.prototype.create = function () { var element = (this.type !== '') ? document.createElementNS('http://www.w3.org/2000/svg', this.type) : document.createTextNode(this.content); this.remove(); this.element = element; if (this.type !== '') { this.update(this.attributes); if (Array.isArray(this.content)) { this.content.forEach(function (element) { if (typeof element === 'string') { // Treat element as a text node. element = { type: '', attributes: null, content: element }; } if (typeof element !== 'object' || typeof element.type !== 'string') { throw 'Invalid element configuration!'; } this.add(element.type, element.attributes, element.content); }, this); this.content = null; } else if ((/string|number|boolean/).test(typeof this.content)) { element.textContent = this.content; } } if (this.parent !== null && this.parent.element !== null) { this.parent.element.appendChild(element); } return element; }; /** * Merge source and target attributes. If both source and attributes contain the same set of keys, values from * attributes are used. * * @param {object} source Source object attributes. * @param {object} attributes New object attributes. * * @return {object} Merged set of attributes. */ SVGElement.mergeAttributes = function (source, attributes) { var merged = {}; if (typeof source === 'object') { Object.keys(source).forEach(function (key){ merged[key] = source[key]; }); } if (typeof attributes === 'object') { Object.keys(attributes).forEach(function (key){ merged[key] = attributes[key]; }); } return merged; };