WordcloudUtils.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. /* *
  2. *
  3. * Experimental Highcharts module which enables visualization of a word cloud.
  4. *
  5. * (c) 2016-2021 Highsoft AS
  6. * Authors: Jon Arild Nygard
  7. *
  8. * License: www.highcharts.com/license
  9. *
  10. * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
  11. * */
  12. import PolygonMixin from '../../Mixins/Polygon.js';
  13. var isPolygonsColliding = PolygonMixin.isPolygonsColliding, movePolygon = PolygonMixin.movePolygon;
  14. import U from '../../Core/Utilities.js';
  15. var extend = U.extend, find = U.find, isNumber = U.isNumber, isObject = U.isObject, merge = U.merge;
  16. /* *
  17. *
  18. * Namespace
  19. *
  20. * */
  21. var WordcloudUtils;
  22. (function (WordcloudUtils) {
  23. /* *
  24. *
  25. * Functions
  26. *
  27. * */
  28. /**
  29. * Detects if there is a collision between two rectangles.
  30. *
  31. * @private
  32. * @function isRectanglesIntersecting
  33. *
  34. * @param {Highcharts.PolygonBoxObject} r1
  35. * First rectangle.
  36. *
  37. * @param {Highcharts.PolygonBoxObject} r2
  38. * Second rectangle.
  39. *
  40. * @return {boolean}
  41. * Returns true if the rectangles overlap.
  42. */
  43. function isRectanglesIntersecting(r1, r2) {
  44. return !(r2.left > r1.right ||
  45. r2.right < r1.left ||
  46. r2.top > r1.bottom ||
  47. r2.bottom < r1.top);
  48. }
  49. WordcloudUtils.isRectanglesIntersecting = isRectanglesIntersecting;
  50. /**
  51. * Detects if a word collides with any previously placed words.
  52. *
  53. * @private
  54. * @function intersectsAnyWord
  55. *
  56. * @param {Highcharts.Point} point
  57. * Point which the word is connected to.
  58. *
  59. * @param {Array<Highcharts.Point>} points
  60. * Previously placed points to check against.
  61. *
  62. * @return {boolean}
  63. * Returns true if there is collision.
  64. */
  65. function intersectsAnyWord(point, points) {
  66. var intersects = false, rect = point.rect, polygon = point.polygon, lastCollidedWith = point.lastCollidedWith, isIntersecting = function (p) {
  67. var result = isRectanglesIntersecting(rect, p.rect);
  68. if (result &&
  69. (point.rotation % 90 || p.rotation % 90)) {
  70. result = isPolygonsColliding(polygon, p.polygon);
  71. }
  72. return result;
  73. };
  74. // If the point has already intersected a different point, chances are
  75. // they are still intersecting. So as an enhancement we check this
  76. // first.
  77. if (lastCollidedWith) {
  78. intersects = isIntersecting(lastCollidedWith);
  79. // If they no longer intersects, remove the cache from the point.
  80. if (!intersects) {
  81. delete point.lastCollidedWith;
  82. }
  83. }
  84. // If not already found, then check if we can find a point that is
  85. // intersecting.
  86. if (!intersects) {
  87. intersects = !!find(points, function (p) {
  88. var result = isIntersecting(p);
  89. if (result) {
  90. point.lastCollidedWith = p;
  91. }
  92. return result;
  93. });
  94. }
  95. return intersects;
  96. }
  97. WordcloudUtils.intersectsAnyWord = intersectsAnyWord;
  98. /**
  99. * Gives a set of cordinates for an Archimedian Spiral.
  100. *
  101. * @private
  102. * @function archimedeanSpiral
  103. *
  104. * @param {number} attempt
  105. * How far along the spiral we have traversed.
  106. *
  107. * @param {Highcharts.WordcloudSpiralParamsObject} [params]
  108. * Additional parameters.
  109. *
  110. * @return {boolean|Highcharts.PositionObject}
  111. * Resulting coordinates, x and y. False if the word should be dropped from
  112. * the visualization.
  113. */
  114. function archimedeanSpiral(attempt, params) {
  115. var field = params.field, result = false, maxDelta = (field.width * field.width) + (field.height * field.height), t = attempt * 0.8; // 0.2 * 4 = 0.8. Enlarging the spiral.
  116. // Emergency brake. TODO make spiralling logic more foolproof.
  117. if (attempt <= 10000) {
  118. result = {
  119. x: t * Math.cos(t),
  120. y: t * Math.sin(t)
  121. };
  122. if (!(Math.min(Math.abs(result.x), Math.abs(result.y)) < maxDelta)) {
  123. result = false;
  124. }
  125. }
  126. return result;
  127. }
  128. WordcloudUtils.archimedeanSpiral = archimedeanSpiral;
  129. /**
  130. * Gives a set of cordinates for an rectangular spiral.
  131. *
  132. * @private
  133. * @function squareSpiral
  134. *
  135. * @param {number} attempt
  136. * How far along the spiral we have traversed.
  137. *
  138. * @param {Highcharts.WordcloudSpiralParamsObject} [params]
  139. * Additional parameters.
  140. *
  141. * @return {boolean|Highcharts.PositionObject}
  142. * Resulting coordinates, x and y. False if the word should be dropped from
  143. * the visualization.
  144. */
  145. function squareSpiral(attempt, params) {
  146. var a = attempt * 4, k = Math.ceil((Math.sqrt(a) - 1) / 2), t = 2 * k + 1, m = Math.pow(t, 2), isBoolean = function (x) {
  147. return typeof x === 'boolean';
  148. }, result = false;
  149. t -= 1;
  150. if (attempt <= 10000) {
  151. if (isBoolean(result) && a >= m - t) {
  152. result = {
  153. x: k - (m - a),
  154. y: -k
  155. };
  156. }
  157. m -= t;
  158. if (isBoolean(result) && a >= m - t) {
  159. result = {
  160. x: -k,
  161. y: -k + (m - a)
  162. };
  163. }
  164. m -= t;
  165. if (isBoolean(result)) {
  166. if (a >= m - t) {
  167. result = {
  168. x: -k + (m - a),
  169. y: k
  170. };
  171. }
  172. else {
  173. result = {
  174. x: k,
  175. y: k - (m - a - t)
  176. };
  177. }
  178. }
  179. result.x *= 5;
  180. result.y *= 5;
  181. }
  182. return result;
  183. }
  184. WordcloudUtils.squareSpiral = squareSpiral;
  185. /**
  186. * Gives a set of cordinates for an rectangular spiral.
  187. *
  188. * @private
  189. * @function rectangularSpiral
  190. *
  191. * @param {number} attempt
  192. * How far along the spiral we have traversed.
  193. *
  194. * @param {Highcharts.WordcloudSpiralParamsObject} [params]
  195. * Additional parameters.
  196. *
  197. * @return {boolean|Higcharts.PositionObject}
  198. * Resulting coordinates, x and y. False if the word should be dropped from
  199. * the visualization.
  200. */
  201. function rectangularSpiral(attempt, params) {
  202. var result = squareSpiral(attempt, params), field = params.field;
  203. if (result) {
  204. result.x *= field.ratioX;
  205. result.y *= field.ratioY;
  206. }
  207. return result;
  208. }
  209. WordcloudUtils.rectangularSpiral = rectangularSpiral;
  210. /**
  211. * @private
  212. * @function getRandomPosition
  213. *
  214. * @param {number} size
  215. * Random factor.
  216. *
  217. * @return {number}
  218. * Random position.
  219. */
  220. function getRandomPosition(size) {
  221. return Math.round((size * (Math.random() + 0.5)) / 2);
  222. }
  223. WordcloudUtils.getRandomPosition = getRandomPosition;
  224. /**
  225. * Calculates the proper scale to fit the cloud inside the plotting area.
  226. *
  227. * @private
  228. * @function getScale
  229. *
  230. * @param {number} targetWidth
  231. * Width of target area.
  232. *
  233. * @param {number} targetHeight
  234. * Height of target area.
  235. *
  236. * @param {object} field
  237. * The playing field.
  238. *
  239. * @param {Highcharts.Series} series
  240. * Series object.
  241. *
  242. * @return {number}
  243. * Returns the value to scale the playing field up to the size of the target
  244. * area.
  245. */
  246. function getScale(targetWidth, targetHeight, field) {
  247. var height = Math.max(Math.abs(field.top), Math.abs(field.bottom)) * 2, width = Math.max(Math.abs(field.left), Math.abs(field.right)) * 2, scaleX = width > 0 ? 1 / width * targetWidth : 1, scaleY = height > 0 ? 1 / height * targetHeight : 1;
  248. return Math.min(scaleX, scaleY);
  249. }
  250. WordcloudUtils.getScale = getScale;
  251. /**
  252. * Calculates what is called the playing field. The field is the area which
  253. * all the words are allowed to be positioned within. The area is
  254. * proportioned to match the target aspect ratio.
  255. *
  256. * @private
  257. * @function getPlayingField
  258. *
  259. * @param {number} targetWidth
  260. * Width of the target area.
  261. *
  262. * @param {number} targetHeight
  263. * Height of the target area.
  264. *
  265. * @param {Array<Highcharts.Point>} data
  266. * Array of points.
  267. *
  268. * @param {object} data.dimensions
  269. * The height and width of the word.
  270. *
  271. * @return {object}
  272. * The width and height of the playing field.
  273. */
  274. function getPlayingField(targetWidth, targetHeight, data) {
  275. var info = data.reduce(function (obj, point) {
  276. var dimensions = point.dimensions, x = Math.max(dimensions.width, dimensions.height);
  277. // Find largest height.
  278. obj.maxHeight = Math.max(obj.maxHeight, dimensions.height);
  279. // Find largest width.
  280. obj.maxWidth = Math.max(obj.maxWidth, dimensions.width);
  281. // Sum up the total maximum area of all the words.
  282. obj.area += x * x;
  283. return obj;
  284. }, {
  285. maxHeight: 0,
  286. maxWidth: 0,
  287. area: 0
  288. }),
  289. /**
  290. * Use largest width, largest height, or root of total area to give
  291. * size to the playing field.
  292. */
  293. x = Math.max(info.maxHeight, // Have enough space for the tallest word
  294. info.maxWidth, // Have enough space for the broadest word
  295. // Adjust 15% to account for close packing of words
  296. Math.sqrt(info.area) * 0.85), ratioX = targetWidth > targetHeight ? targetWidth / targetHeight : 1, ratioY = targetHeight > targetWidth ? targetHeight / targetWidth : 1;
  297. return {
  298. width: x * ratioX,
  299. height: x * ratioY,
  300. ratioX: ratioX,
  301. ratioY: ratioY
  302. };
  303. }
  304. WordcloudUtils.getPlayingField = getPlayingField;
  305. /**
  306. * Calculates a number of degrees to rotate, based upon a number of
  307. * orientations within a range from-to.
  308. *
  309. * @private
  310. * @function getRotation
  311. *
  312. * @param {number} [orientations]
  313. * Number of orientations.
  314. *
  315. * @param {number} [index]
  316. * Index of point, used to decide orientation.
  317. *
  318. * @param {number} [from]
  319. * The smallest degree of rotation.
  320. *
  321. * @param {number} [to]
  322. * The largest degree of rotation.
  323. *
  324. * @return {boolean|number}
  325. * Returns the resulting rotation for the word. Returns false if invalid
  326. * input parameters.
  327. */
  328. function getRotation(orientations, index, from, to) {
  329. var result = false, // Default to false
  330. range, intervals, orientation;
  331. // Check if we have valid input parameters.
  332. if (isNumber(orientations) &&
  333. isNumber(index) &&
  334. isNumber(from) &&
  335. isNumber(to) &&
  336. orientations > 0 &&
  337. index > -1 &&
  338. to > from) {
  339. range = to - from;
  340. intervals = range / (orientations - 1 || 1);
  341. orientation = index % orientations;
  342. result = from + (orientation * intervals);
  343. }
  344. return result;
  345. }
  346. WordcloudUtils.getRotation = getRotation;
  347. /**
  348. * Calculates the spiral positions and store them in scope for quick access.
  349. *
  350. * @private
  351. * @function getSpiral
  352. *
  353. * @param {Function} fn
  354. * The spiral function.
  355. *
  356. * @param {object} params
  357. * Additional parameters for the spiral.
  358. *
  359. * @return {Function}
  360. * Function with access to spiral positions.
  361. */
  362. function getSpiral(fn, params) {
  363. var length = 10000, i, arr = [];
  364. for (i = 1; i < length; i++) {
  365. // @todo unnecessary amount of precaclulation
  366. arr.push(fn(i, params));
  367. }
  368. return function (attempt) {
  369. return attempt <= length ? arr[attempt - 1] : false;
  370. };
  371. }
  372. WordcloudUtils.getSpiral = getSpiral;
  373. /**
  374. * Detects if a word is placed outside the playing field.
  375. *
  376. * @private
  377. * @function outsidePlayingField
  378. *
  379. * @param {Highcharts.PolygonBoxObject} rect
  380. * The word box.
  381. *
  382. * @param {Highcharts.WordcloudFieldObject} field
  383. * The width and height of the playing field.
  384. *
  385. * @return {boolean}
  386. * Returns true if the word is placed outside the field.
  387. */
  388. function outsidePlayingField(rect, field) {
  389. var playingField = {
  390. left: -(field.width / 2),
  391. right: field.width / 2,
  392. top: -(field.height / 2),
  393. bottom: field.height / 2
  394. };
  395. return !(playingField.left < rect.left &&
  396. playingField.right > rect.right &&
  397. playingField.top < rect.top &&
  398. playingField.bottom > rect.bottom);
  399. }
  400. WordcloudUtils.outsidePlayingField = outsidePlayingField;
  401. /**
  402. * Check if a point intersects with previously placed words, or if it goes
  403. * outside the field boundaries. If a collision, then try to adjusts the
  404. * position.
  405. *
  406. * @private
  407. * @function intersectionTesting
  408. *
  409. * @param {Highcharts.Point} point
  410. * Point to test for intersections.
  411. *
  412. * @param {Highcharts.WordcloudTestOptionsObject} options
  413. * Options object.
  414. *
  415. * @return {boolean|Highcharts.PositionObject}
  416. * Returns an object with how much to correct the positions. Returns false
  417. * if the word should not be placed at all.
  418. */
  419. function intersectionTesting(point, options) {
  420. var placed = options.placed, field = options.field, rectangle = options.rectangle, polygon = options.polygon, spiral = options.spiral, attempt = 1, delta = {
  421. x: 0,
  422. y: 0
  423. },
  424. // Make a copy to update values during intersection testing.
  425. rect = point.rect = extend({}, rectangle);
  426. point.polygon = polygon;
  427. point.rotation = options.rotation;
  428. /* while w intersects any previously placed words:
  429. do {
  430. move w a little bit along a spiral path
  431. } while any part of w is outside the playing field and
  432. the spiral radius is still smallish */
  433. while (delta !== false &&
  434. (intersectsAnyWord(point, placed) ||
  435. outsidePlayingField(rect, field))) {
  436. delta = spiral(attempt);
  437. if (isObject(delta)) {
  438. // Update the DOMRect with new positions.
  439. rect.left = rectangle.left + delta.x;
  440. rect.right = rectangle.right + delta.x;
  441. rect.top = rectangle.top + delta.y;
  442. rect.bottom = rectangle.bottom + delta.y;
  443. point.polygon = movePolygon(delta.x, delta.y, polygon);
  444. }
  445. attempt++;
  446. }
  447. return delta;
  448. }
  449. WordcloudUtils.intersectionTesting = intersectionTesting;
  450. /**
  451. * Extends the playing field to have enough space to fit a given word.
  452. *
  453. * @private
  454. * @function extendPlayingField
  455. *
  456. * @param {Highcharts.WordcloudFieldObject} field
  457. * The width, height and ratios of a playing field.
  458. *
  459. * @param {Highcharts.PolygonBoxObject} rectangle
  460. * The bounding box of the word to add space for.
  461. *
  462. * @return {Highcharts.WordcloudFieldObject}
  463. * Returns the extended playing field with updated height and width.
  464. */
  465. function extendPlayingField(field, rectangle) {
  466. var height, width, ratioX, ratioY, x, extendWidth, extendHeight, result;
  467. if (isObject(field) && isObject(rectangle)) {
  468. height = (rectangle.bottom - rectangle.top);
  469. width = (rectangle.right - rectangle.left);
  470. ratioX = field.ratioX;
  471. ratioY = field.ratioY;
  472. // Use the same variable to extend both the height and width.
  473. x = ((width * ratioX) > (height * ratioY)) ? width : height;
  474. // Multiply variable with ratios to preserve aspect ratio.
  475. extendWidth = x * ratioX;
  476. extendHeight = x * ratioY;
  477. // Calculate the size of the new field after adding
  478. // space for the word.
  479. result = merge(field, {
  480. // Add space on the left and right.
  481. width: field.width + (extendWidth * 2),
  482. // Add space on the top and bottom.
  483. height: field.height + (extendHeight * 2)
  484. });
  485. }
  486. else {
  487. result = field;
  488. }
  489. // Return the new extended field.
  490. return result;
  491. }
  492. WordcloudUtils.extendPlayingField = extendPlayingField;
  493. /**
  494. * If a rectangle is outside a give field, then the boundaries of the field
  495. * is adjusted accordingly. Modifies the field object which is passed as the
  496. * first parameter.
  497. *
  498. * @private
  499. * @function updateFieldBoundaries
  500. *
  501. * @param {Highcharts.WordcloudFieldObject} field
  502. * The bounding box of a playing field.
  503. *
  504. * @param {Highcharts.PolygonBoxObject} rectangle
  505. * The bounding box for a placed point.
  506. *
  507. * @return {Highcharts.WordcloudFieldObject}
  508. * Returns a modified field object.
  509. */
  510. function updateFieldBoundaries(field, rectangle) {
  511. // @todo improve type checking.
  512. if (!isNumber(field.left) || field.left > rectangle.left) {
  513. field.left = rectangle.left;
  514. }
  515. if (!isNumber(field.right) || field.right < rectangle.right) {
  516. field.right = rectangle.right;
  517. }
  518. if (!isNumber(field.top) || field.top > rectangle.top) {
  519. field.top = rectangle.top;
  520. }
  521. if (!isNumber(field.bottom) || field.bottom < rectangle.bottom) {
  522. field.bottom = rectangle.bottom;
  523. }
  524. return field;
  525. }
  526. WordcloudUtils.updateFieldBoundaries = updateFieldBoundaries;
  527. })(WordcloudUtils || (WordcloudUtils = {}));
  528. /* *
  529. *
  530. * Default export
  531. *
  532. * */
  533. export default WordcloudUtils;