Plugin/textoverlap/index.ts

/**
 * Copyright (c) 2017 ~ present NAVER Corp.
 * billboard.js project is licensed under the MIT license
 */
import {voronoi as d3Voronoi} from "d3-voronoi";
import {
	polygonCentroid as d3PolygonCentroid,
	polygonArea as d3PolygonArea
} from "d3-polygon";
import {
	select as d3Select,
	selectAll as d3SelectAll
} from "d3-selection";
import {loadConfig} from "../../config/config";
import Plugin from "../Plugin";
import Options from "./Options";

/**
 * 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.
 * - **Required modules:**
 *   - [d3-selection](https://github.com/d3/d3-selection)
 *   - [d3-polygon](https://github.com/d3/d3-polygon)
 *   - [d3-voronoi](https://github.com/d3/d3-voronoi)
 * @class plugin-textoverlap
 * @requires d3-selection
 * @requires d3-polygon
 * @requires d3-voronoi
 * @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.esm";
 *
 * bb.generate({
 *     plugins: [
 *        new TextOverlap({ ... })
 *     ]
 * })
 */
export default class TextOverlap extends Plugin {
	private config;

	constructor(options) {
		super(options);
		this.config = new Options();

		return this;
	}

	$init(): void {
		loadConfig.call(this, this.options);
	}

	$redraw(): void {
		const text = d3SelectAll(this.config.selector);

		!text.empty() && this.preventLabelOverlap(text);
	}

	/**
	 * Generates the voronoi layout for data labels
	 * @param {object} data Indices values
	 * @returns {object} Voronoi layout points and corresponding Data points
	 * @private
	 */
	generateVoronoi(data) {
		const {$$} = this;
		const {scale} = $$;
		const [min, max] = ["x", "y"].map(v => scale[v].domain());

		[min[1], max[0]] = [max[0], min[1]];

		return d3Voronoi()
			.extent([min, max])
			.polygons(data);
	}

	/**
	 * Set text label's position to preventg overlap.
	 * @param {d3Selection} text target text selection
	 * @private
	 */
	preventLabelOverlap(text): void {
		const {extent, area} = this.config;
		const cells = this.generateVoronoi(text.data().map(v => [v.x, v.value]));
		let i = 0;

		text.each(function() {
			const cell = cells[i++];

			if (cell && this) {
				const [x, y] = cell.data;
				const [cx, cy] = d3PolygonCentroid(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");

				d3Select(this)
					// @ts-ignore
					.attr("display", d3PolygonArea(cell) < area ? "none" : null)
					.attr("text-anchor", txtAnchor)
					.attr("dy", `0.${angle === 1 ? 71 : 35}em`)
					.attr("transform", `translate(${xTranslate}, ${yTranslate})`);
			}
		});
	}
}