UNPKG

billboard.js

Version:

Re-usable easy interface JavaScript chart library, based on D3 v4+

331 lines (323 loc) 9.01 kB
/*! * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license * * billboard.js, JavaScript chart library * https://naver.github.io/billboard.js/ * * @version 4.0.1 * @requires billboard.js * @summary billboard.js plugin */ import { Delaunay } from 'd3-delaunay'; /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ /** * Compute the signed area of a polygon using the Shoelace formula. * @param {Array} polygon Array of [x, y] coordinates * @returns {number} Signed area of the polygon * @see https://en.wikipedia.org/wiki/Shoelace_formula */ function polygonArea(polygon) { const n = polygon.length; let area = 0; let b = polygon[n - 1]; for (let i = 0; i < n; i++) { const a = b; b = polygon[i]; area += a[1] * b[0] - a[0] * b[1]; } return area / 2; } /** * Compute the centroid of a polygon. * @param {Array} polygon Array of [x, y] coordinates * @returns {Array} Centroid [x, y] of the polygon */ function polygonCentroid(polygon) { const n = polygon.length; let x = 0; let y = 0; let k = 0; let b = polygon[n - 1]; for (let i = 0; i < n; i++) { const a = b; b = polygon[i]; const c = a[0] * b[1] - b[0] * a[1]; k += c; x += (a[0] + b[0]) * c; y += (a[1] + b[1]) * c; } k *= 3; return [x / k, y / k]; } /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license * @ignore */ const isDefined = (v) => typeof v !== "undefined"; const isObjectType = (v) => typeof v === "object"; /** * Load configuration option * @param {object} config User's generation config value * @private */ function loadConfig(config) { const thisConfig = this.config; let target; let keys; let read; const find = () => { const key = keys.shift(); if (key && target && isObjectType(target) && key in target) { target = target[key]; return find(); } else if (!key) { return target; } return undefined; }; Object.keys(thisConfig).forEach(key => { target = config; keys = key.split("_"); read = find(); if (isDefined(read)) { thisConfig[key] = read; } }); // only should run in the ChartInternal context if (this.api) { this.state.orgConfig = config; } } /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ /** * Base class to generate billboard.js plugin * @class Plugin */ /** * Version info string for plugin * @name version * @static * @memberof Plugin * @type {string} * @example * bb.plugin.stanford.version; // ex) 1.9.0 */ class Plugin { $$; options; config; static version = "4.0.1"; /** * Constructor * @param {Any} options config option object * @private */ constructor(options = {}) { this.options = options; } /** * Load plugin config from options * @private */ loadConfig() { loadConfig.call(this, this.options); } /** * Lifecycle hook for 'beforeInit' phase. * @private */ $beforeInit() { } /** * Lifecycle hook for 'init' phase. * @private */ $init() { } /** * Lifecycle hook for 'afterInit' phase. * @private */ $afterInit() { } /** * Lifecycle hook for 'redraw' phase. * @private */ $redraw() { } /** * Lifecycle hook for 'willDestroy' phase. * @private */ $willDestroy() { Object.keys(this).forEach(key => { this[key] = null; delete this[key]; }); } } /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ /** * TextOverlap plugin option class * @class TextOverlapOptions * @param {Options} options TextOverlap plugin options * @augments Plugin * @returns {TextOverlapOptions} * @private */ class Options { constructor() { return { /** * Selector string for target text nodes within chart element. * - **NOTE:** If no value is given, defaults to data label text elements. * @name selector * @memberof plugin-textoverlap * @type {string} * @default undefined * @example * // selector for data label text nodes * selector: ".bb-texts text" */ selector: undefined, /** * Extent of label overlap prevention. * @name extent * @memberof plugin-textoverlap * @type {number} * @default 1 * @example * extent: 1 */ extent: 1, /** * Minimum area needed to show a data label. * @name area * @memberof plugin-textoverlap * @type {number} * @default 0 * @example * area: 0 */ area: 0 }; } } /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ /** * TextOverlap plugin<br> * Prevents label overlap using [Voronoi layout](https://en.wikipedia.org/wiki/Voronoi_diagram). * - **NOTE:** * - Plugins aren't built-in. Need to be loaded or imported to be used. * - Non required modules from billboard.js core, need to be installed separately. * - Appropriate and works for axis based chart. * - **Required modules:** * - [d3-delaunay](https://github.com/d3/d3-delaunay) * @class plugin-textoverlap * @requires d3-delaunay * @param {object} options TextOverlap plugin options * @augments Plugin * @returns {TextOverlap} * @example * // Plugin must be loaded before the use. * <script src="$YOUR_PATH/plugin/billboardjs-plugin-textoverlap.js"></script> * * var chart = bb.generate({ * data: { * columns: [ ... ] * }, * ... * plugins: [ * new bb.plugin.textoverlap({ * selector: ".bb-texts text", * extent: 8, * area: 3 * }) * ] * }); * @example * import {bb} from "billboard.js"; * import TextOverlap from "billboard.js/dist/billboardjs-plugin-textoverlap"; * * bb.generate({ * plugins: [ * new TextOverlap({ ... }) * ] * }) */ class TextOverlap extends Plugin { constructor(options) { super(options); this.config = new Options(); return this; } $init() { this.loadConfig(); } $redraw() { const { $$: { $el }, config: { selector } } = this; const text = selector ? $el.main.selectAll(selector) : $el.text; !text.empty() && this.preventLabelOverlap(text); } /** * Generates the voronoi layout for data labels * @param {Array} points Indices values * @returns {object} Voronoi layout points and corresponding Data points * @private */ generateVoronoi(points) { const { $$ } = this; const { scale } = $$; const [min, max] = ["x", "y"].map(v => scale[v].domain()); [min[1], max[0]] = [max[0], min[1]]; return Delaunay .from(points) .voronoi([ ...min, ...max ]); // bounds = [xmin, ymin, xmax, ymax], default value: [0, 0, 960, 500] } /** * Set text label's position to preventg overlap. * @param {d3Selection} text target text selection * @private */ preventLabelOverlap(text) { const { extent, area } = this.config; const points = text.data().map(v => [v.index, v.value]); const voronoi = this.generateVoronoi(points); let i = 0; text.each(function () { const cell = voronoi.cellPolygon(i); if (cell && this) { const [x, y] = points[i]; const [cx, cy] = polygonCentroid(cell); const cellArea = Math.abs(polygonArea(cell)); const angle = Math.round(Math.atan2(cy - y, cx - x) / Math.PI * 2); const xTranslate = extent * (angle === 0 ? 1 : -1); const yTranslate = angle === -1 ? -extent : extent + 5; const txtAnchor = Math.abs(angle) === 1 ? "middle" : (angle === 0 ? "start" : "end"); this.style.display = cellArea < area ? "none" : ""; this.setAttribute("text-anchor", txtAnchor); this.setAttribute("dy", `0.${angle === 1 ? 71 : 35}em`); this.setAttribute("transform", `translate(${xTranslate}, ${yTranslate})`); } i++; }); } } export { TextOverlap as default };