Plugin/textoverlap/index.ts

  1. /**
  2. * Copyright (c) 2017 ~ present NAVER Corp.
  3. * billboard.js project is licensed under the MIT license
  4. */
  5. import type {d3Selection} from "billboard.js/types/types";
  6. import {Delaunay as d3Delaunay} from "d3-delaunay";
  7. import {polygonArea as d3PolygonArea, polygonCentroid as d3PolygonCentroid} from "d3-polygon";
  8. import {loadConfig} from "../../config/config";
  9. import Plugin from "../Plugin";
  10. import Options from "./Options";
  11. /**
  12. * TextOverlap plugin<br>
  13. * Prevents label overlap using [Voronoi layout](https://en.wikipedia.org/wiki/Voronoi_diagram).
  14. * - **NOTE:**
  15. * - Plugins aren't built-in. Need to be loaded or imported to be used.
  16. * - Non required modules from billboard.js core, need to be installed separately.
  17. * - Appropriate and works for axis based chart.
  18. * - **Required modules:**
  19. * - [d3-polygon](https://github.com/d3/d3-polygon)
  20. * - [d3-delaunay](https://github.com/d3/d3-delaunay)
  21. * @class plugin-textoverlap
  22. * @requires d3-polygon
  23. * @requires d3-delaunay
  24. * @param {object} options TextOverlap plugin options
  25. * @augments Plugin
  26. * @returns {TextOverlap}
  27. * @example
  28. * // Plugin must be loaded before the use.
  29. * <script src="$YOUR_PATH/plugin/billboardjs-plugin-textoverlap.js"></script>
  30. *
  31. * var chart = bb.generate({
  32. * data: {
  33. * columns: [ ... ]
  34. * },
  35. * ...
  36. * plugins: [
  37. * new bb.plugin.textoverlap({
  38. * selector: ".bb-texts text",
  39. * extent: 8,
  40. * area: 3
  41. * })
  42. * ]
  43. * });
  44. * @example
  45. * import {bb} from "billboard.js";
  46. * import TextOverlap from "billboard.js/dist/billboardjs-plugin-textoverlap";
  47. *
  48. * bb.generate({
  49. * plugins: [
  50. * new TextOverlap({ ... })
  51. * ]
  52. * })
  53. */
  54. export default class TextOverlap extends Plugin {
  55. private config;
  56. constructor(options?: Options) {
  57. super(options);
  58. this.config = new Options();
  59. return this;
  60. }
  61. $init(): void {
  62. loadConfig.call(this, this.options);
  63. }
  64. $redraw(): void {
  65. const {$$: {$el}, config: {selector}} = this;
  66. const text = selector ? $el.main.selectAll(selector) : $el.text;
  67. !text.empty() && this.preventLabelOverlap(text);
  68. }
  69. /**
  70. * Generates the voronoi layout for data labels
  71. * @param {Array} points Indices values
  72. * @returns {object} Voronoi layout points and corresponding Data points
  73. * @private
  74. */
  75. generateVoronoi(points: [number, number][]) {
  76. const {$$} = this;
  77. const {scale} = $$;
  78. const [min, max] = ["x", "y"].map(v => scale[v].domain());
  79. [min[1], max[0]] = [max[0], min[1]];
  80. return d3Delaunay
  81. .from(points)
  82. .voronoi([
  83. ...min as [number, number],
  84. ...max as [number, number]
  85. ]); // bounds = [xmin, ymin, xmax, ymax], default value: [0, 0, 960, 500]
  86. }
  87. /**
  88. * Set text label's position to preventg overlap.
  89. * @param {d3Selection} text target text selection
  90. * @private
  91. */
  92. preventLabelOverlap(text: d3Selection): void {
  93. const {extent, area} = this.config;
  94. const points = text.data().map(v => [v.index, v.value]) as [number, number][];
  95. const voronoi = this.generateVoronoi(points);
  96. let i = 0;
  97. text.each(function() {
  98. const cell = voronoi.cellPolygon(i);
  99. if (cell && this) {
  100. const [x, y] = points[i];
  101. // @ts-ignore wrong type definiton for d3PolygonCentroid
  102. const [cx, cy] = d3PolygonCentroid(cell);
  103. // @ts-ignore wrong type definiton for d3PolygonArea
  104. const polygonArea = Math.abs(d3PolygonArea(cell));
  105. const angle = Math.round(Math.atan2(cy - y, cx - x) / Math.PI * 2);
  106. const xTranslate = extent * (angle === 0 ? 1 : -1);
  107. const yTranslate = angle === -1 ? -extent : extent + 5;
  108. const txtAnchor = Math.abs(angle) === 1 ?
  109. "middle" :
  110. (angle === 0 ? "start" : "end");
  111. this.style.display = polygonArea < area ? "none" : "";
  112. this.setAttribute("text-anchor", txtAnchor);
  113. this.setAttribute("dy", `0.${angle === 1 ? 71 : 35}em`);
  114. this.setAttribute("transform", `translate(${xTranslate}, ${yTranslate})`);
  115. }
  116. i++;
  117. });
  118. }
  119. }