Plugin/textoverlap/index.ts

/**
 * Copyright (c) 2017 ~ present NAVER Corp.
 * billboard.js project is licensed under the MIT license
 */
import type {d3Selection} from "billboard.js/types/types";
import {Delaunay as d3Delaunay} from "d3-delaunay";
import {polygonArea as d3PolygonArea, polygonCentroid as d3PolygonCentroid} from "d3-polygon";
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.
 *   - Appropriate and works for axis based chart.
 * - **Required modules:**
 *   - [d3-polygon](https://github.com/d3/d3-polygon)
 *   - [d3-delaunay](https://github.com/d3/d3-delaunay)
 * @class plugin-textoverlap
 * @requires d3-polygon
 * @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({ ... })
 *     ]
 * })
 */
export default class TextOverlap extends Plugin {
	private config;

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

		return this;
	}

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

	$redraw(): void {
		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: [number, number][]) {
		const {$$} = this;
		const {scale} = $$;
		const [min, max] = ["x", "y"].map(v => scale[v].domain());

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

		return d3Delaunay
			.from(points)
			.voronoi([
				...min as [number, number],
				...max as [number, number]
			]); // 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: d3Selection): void {
		const {extent, area} = this.config;
		const points = text.data().map(v => [v.index, v.value]) as [number, number][];
		const voronoi = this.generateVoronoi(points);
		let i = 0;

		text.each(function() {
			const cell = voronoi.cellPolygon(i);

			if (cell && this) {
				const [x, y] = points[i];
				// @ts-ignore wrong type definiton for d3PolygonCentroid
				const [cx, cy] = d3PolygonCentroid(cell);

				// @ts-ignore wrong type definiton for d3PolygonArea
				const polygonArea = Math.abs(d3PolygonArea(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 = polygonArea < area ? "none" : "";
				this.setAttribute("text-anchor", txtAnchor);
				this.setAttribute("dy", `0.${angle === 1 ? 71 : 35}em`);
				this.setAttribute("transform", `translate(${xTranslate}, ${yTranslate})`);
			}

			i++;
		});
	}
}