Skip to main content
Module

x/billboardjs/Chart/api/export.ts

πŸ“Š Re-usable, easy interface JavaScript chart library based on D3.js
Latest
File
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */import {namespaces as d3Namespaces} from "d3-selection";import {document, window} from "../../module/browser";import {getCssRules, isFunction, mergeObj, toArray} from "../../module/util";
type TExportOption = TSize & { preserveAspectRatio: boolean, preserveFontStyle: boolean, mimeType: string};
type TSize = {x?: number, y?: number, width: number, height: number};
type TTextGlyph = { [key: string]: TSize & { fill: string, fontFamily: string, fontSize: string, textAnchor: string, transform: string }};
/** * Encode to base64 * @param {string} str string to be encoded * @returns {string} * @private * @see https://developer.mozilla.org/ko/docs/Web/API/WindowBase64/Base64_encoding_and_decoding */const b64EncodeUnicode = (str: string): string => window.btoa?.( encodeURIComponent(str) .replace(/%([0-9A-F]{2})/g, (match, p: number | string): string => String.fromCharCode(Number(`0x${p}`))) );
/** * Convert svg node to data url * @param {HTMLElement} node target node * @param {object} option object containing {width, height, preserveAspectRatio} * @param {object} orgSize object containing {width, height} * @returns {string} * @private */function nodeToSvgDataUrl(node, option: TExportOption, orgSize: TSize) { const {width, height} = option || orgSize; const serializer = new XMLSerializer(); const clone = node.cloneNode(true); const cssText = getCssRules(toArray(document.styleSheets)) .filter((r: CSSStyleRule) => r.cssText) .map((r: CSSStyleRule) => r.cssText);
clone.setAttribute("xmlns", d3Namespaces.xhtml);
// remove padding & margin clone.style.margin = "0"; clone.style.padding = "0";
// remove text nodes if (option.preserveFontStyle) { clone.querySelectorAll("text").forEach(t => { t.innerHTML = ""; }); }
const nodeXml = serializer.serializeToString(clone);
// escape css for XML const style = document.createElement("style");
style.appendChild(document.createTextNode(cssText.join("\n")));
const styleXml = serializer.serializeToString(style);
// foreignObject not supported in IE11 and below // https://msdn.microsoft.com/en-us/library/hh834675(v=vs.85).aspx const dataStr = `<svg xmlns="${d3Namespaces.svg}" width="${width}" height="${height}" viewBox="0 0 ${orgSize.width} ${orgSize.height}" preserveAspectRatio="${option?.preserveAspectRatio === false ? "none" : "xMinYMid meet"}"> <foreignObject width="100%" height="100%"> ${styleXml} ${nodeXml.replace(/(url\()[^#]+/g, "$1")} </foreignObject></svg>`;
return `data:image/svg+xml;base64,${b64EncodeUnicode(dataStr)}`;}
/** * Get coordinate of the element * @param {SVGElement} elem Target element * @param {object} svgOffset SVG offset * @returns {object} * @private */function getCoords(elem, svgOffset): TSize { const {top, left} = svgOffset; const {x, y} = elem.getBBox(); const {a, b, c, d, e, f} = elem.getScreenCTM(); const {width, height} = elem.getBoundingClientRect();
return { x: (a * x) + (c * y) + e - left, y: (b * x) + (d * y) + f - top + (height - Math.round(height / 4)), width, height };}
/** * Get text glyph * @param {SVGTextElement} svg Target svg node * @returns {Array} * @private */function getGlyph(svg: SVGElement): TTextGlyph[] { const {left, top} = svg.getBoundingClientRect(); const filterFn = t => t.textContent || t.childElementCount; const glyph: TTextGlyph[] = [];
toArray(svg.querySelectorAll("text")) .filter(filterFn) .forEach((t: SVGTextElement) => { // eslint-disable-line const getStyleFn = (ts: SVGTextElement): TTextGlyph => { const {fill, fontFamily, fontSize, textAnchor, transform} = window.getComputedStyle( ts ); const {x, y, width, height} = getCoords(ts, {left, top});
return { [ts.textContent as string]: { x, y, width, height, fill, fontFamily, fontSize, textAnchor, transform } }; };
if (t.childElementCount > 1) { const text: TTextGlyph[] = [];
toArray(t.querySelectorAll("tspan")) .filter(filterFn) .forEach((ts: SVGTSpanElement) => { glyph.push(getStyleFn(ts)); });
return text; } else { glyph.push(getStyleFn(t)); } });
return glyph;}
/** * Render text glyph * - NOTE: Called when the 'preserveFontStyle' option is true * @param {CanvasRenderingContext2D} ctx Canvas context * @param {Array} glyph Text glyph array * @private */function renderText(ctx, glyph): void { glyph.forEach(g => { Object.keys(g).forEach(key => { const {x, y, width, height, fill, fontFamily, fontSize, transform} = g[key];
ctx.save();
ctx.font = `${fontSize} ${fontFamily}`; ctx.fillStyle = fill;
if (transform === "none") { ctx.fillText(key, x, y); } else { const args = transform .replace(/(matrix|\(|\))/g, "") .split(",");
if (args.splice(4).every(v => +v === 0)) { args.push(x + width - (width / 4)); args.push(y - height + (height / 3)); } else { args.push(x); args.push(y); }
ctx.transform(...args); ctx.fillText(key, 0, 0); }
ctx.restore(); }); });}
export default { /** * Export chart as an image. * - **NOTE:** * - IE11 and below not work properly due to the lack of the feature(<a href="https://msdn.microsoft.com/en-us/library/hh834675(v=vs.85).aspx">foreignObject</a>) support * - Every style applied to the chart & the basic CSS file(ex. billboard.css) should be at same domain as API call context to get correct styled export image. * @function export * @instance * @memberof Chart * @param {object} option Export option * @param {string} [option.mimeType="image/png"] The desired output image format. (ex. 'image/png' for png, 'image/jpeg' for jpeg format) * @param {number} [option.width={currentWidth}] width * @param {number} [option.height={currentHeigth}] height * @param {boolean} [option.preserveAspectRatio=true] Preserve aspect ratio on given size * @param {boolean} [option.preserveFontStyle=false] Preserve font style(font-family).<br> * **NOTE:** * - This option is useful when outlink web font style's `font-family` are applied to chart's text element. * - Text element's position(especially "transformed") can't be preserved correctly according the page's layout condition. * - If need to preserve accurate text position, embed the web font data within to the page and set `preserveFontStyle=false`. * - Checkout the embed example: <a href="https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html">https://stackblitz.com/edit/zfbya9-8nf9nn?file=index.html</a> * @param {Function} [callback] The callback to be invoked when export is ready. * @returns {string} dataURI * @example * chart.export(); * // --> "data:image/svg+xml;base64,PHN..." * * // Initialize the download automatically * chart.export({mimeType: "image/png"}, dataUrl => { * const link = document.createElement("a"); * * link.download = `${Date.now()}.png`; * link.href = dataUrl; * link.innerHTML = "Download chart as image"; * * document.body.appendChild(link); * }); * * // Resize the exported image * chart.export( * { * width: 800, * height: 600, * preserveAspectRatio: false, * preserveFontStyle: false, * mimeType: "image/png" * }, * dataUrl => { ... } * ); */ export(option?: TExportOption, callback?: (dataUrl: string) => void): string { const $$ = this.internal; const {state, $el: {chart, svg}} = $$; const {width, height} = state.current; const opt = mergeObj({ width, height, preserveAspectRatio: true, preserveFontStyle: false, mimeType: "image/png" }, option) as TExportOption;
const svgDataUrl = nodeToSvgDataUrl(chart.node(), opt, {width, height}); const glyph = opt.preserveFontStyle ? getGlyph(svg.node()) : [];
if (callback && isFunction(callback)) { const img = new Image();
img.crossOrigin = "Anonymous"; img.onload = () => { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d");
canvas.width = opt.width || width; canvas.height = opt.height || height; ctx.drawImage(img, 0, 0);
if (glyph.length) { renderText(ctx, glyph);
// release glyph array glyph.length = 0; }
callback.bind(this)(canvas.toDataURL(opt.mimeType)); };
img.src = svgDataUrl; }
return svgDataUrl; }};