VennUtils.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. /* *
  2. *
  3. * Experimental Highcharts module which enables visualization of a Venn
  4. * diagram.
  5. *
  6. * (c) 2016-2021 Highsoft AS
  7. * Authors: Jon Arild Nygard
  8. *
  9. * Layout algorithm by Ben Frederickson:
  10. * https://www.benfrederickson.com/better-venn-diagrams/
  11. *
  12. * License: www.highcharts.com/license
  13. *
  14. * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
  15. *
  16. * */
  17. import GeometryCirclesModule from '../../Mixins/GeometryCircles.js';
  18. var getAreaOfCircle = GeometryCirclesModule.getAreaOfCircle, getCircleCircleIntersection = GeometryCirclesModule.getCircleCircleIntersection, getOverlapBetweenCirclesByDistance = GeometryCirclesModule.getOverlapBetweenCircles, isPointInsideAllCircles = GeometryCirclesModule.isPointInsideAllCircles, isPointInsideCircle = GeometryCirclesModule.isPointInsideCircle, isPointOutsideAllCircles = GeometryCirclesModule.isPointOutsideAllCircles;
  19. import GeometryMixin from '../../Mixins/Geometry.js';
  20. import NelderMeadMixin from '../../Mixins/NelderMead.js';
  21. var getDistanceBetweenPoints = GeometryMixin.getDistanceBetweenPoints;
  22. import U from '../../Core/Utilities.js';
  23. var extend = U.extend, isArray = U.isArray, isNumber = U.isNumber, isObject = U.isObject, isString = U.isString;
  24. /* *
  25. *
  26. * Namespace
  27. *
  28. * */
  29. var VennUtils;
  30. (function (VennUtils) {
  31. /* *
  32. *
  33. * Properties
  34. *
  35. * */
  36. VennUtils.geometry = GeometryMixin;
  37. VennUtils.geometryCircles = GeometryCirclesModule;
  38. VennUtils.nelderMead = NelderMeadMixin;
  39. /* *
  40. *
  41. * Functions
  42. *
  43. * */
  44. /**
  45. * Takes an array of relations and adds the properties `totalOverlap` and
  46. * `overlapping` to each set. The property `totalOverlap` is the sum of
  47. * value for each relation where this set is included. The property
  48. * `overlapping` is a map of how much this set is overlapping another set.
  49. * NOTE: This algorithm ignores relations consisting of more than 2 sets.
  50. * @private
  51. * @param {Array<Highcharts.VennRelationObject>} relations
  52. * The list of relations that should be sorted.
  53. * @return {Array<Highcharts.VennRelationObject>}
  54. * Returns the modified input relations with added properties `totalOverlap`
  55. * and `overlapping`.
  56. */
  57. function addOverlapToSets(relations) {
  58. // Calculate the amount of overlap per set.
  59. var mapOfIdToProps = relations
  60. // Filter out relations consisting of 2 sets.
  61. .filter(function (relation) {
  62. return relation.sets.length === 2;
  63. })
  64. // Sum up the amount of overlap for each set.
  65. .reduce(function (map, relation) {
  66. var sets = relation.sets;
  67. sets.forEach(function (set, i, arr) {
  68. if (!isObject(map[set])) {
  69. map[set] = {
  70. overlapping: {},
  71. totalOverlap: 0
  72. };
  73. }
  74. map[set].totalOverlap += relation.value;
  75. map[set].overlapping[arr[1 - i]] = relation.value;
  76. });
  77. return map;
  78. }, {});
  79. relations
  80. // Filter out single sets
  81. .filter(isSet)
  82. // Extend the set with the calculated properties.
  83. .forEach(function (set) {
  84. var properties = mapOfIdToProps[set.sets[0]];
  85. extend(set, properties);
  86. });
  87. // Returns the modified relations.
  88. return relations;
  89. }
  90. VennUtils.addOverlapToSets = addOverlapToSets;
  91. /**
  92. * Finds the root of a given function. The root is the input value needed
  93. * for a function to return 0.
  94. *
  95. * See https://en.wikipedia.org/wiki/Bisection_method#Algorithm
  96. *
  97. * TODO: Add unit tests.
  98. *
  99. * @param {Function} f
  100. * The function to find the root of.
  101. * @param {number} a
  102. * The lowest number in the search range.
  103. * @param {number} b
  104. * The highest number in the search range.
  105. * @param {number} [tolerance=1e-10]
  106. * The allowed difference between the returned value and root.
  107. * @param {number} [maxIterations=100]
  108. * The maximum iterations allowed.
  109. * @return {number}
  110. * Root number.
  111. */
  112. function bisect(f, a, b, tolerance, maxIterations) {
  113. var fA = f(a), fB = f(b), nMax = maxIterations || 100, tol = tolerance || 1e-10, delta = b - a, n = 1, x, fX;
  114. if (a >= b) {
  115. throw new Error('a must be smaller than b.');
  116. }
  117. else if (fA * fB > 0) {
  118. throw new Error('f(a) and f(b) must have opposite signs.');
  119. }
  120. if (fA === 0) {
  121. x = a;
  122. }
  123. else if (fB === 0) {
  124. x = b;
  125. }
  126. else {
  127. while (n++ <= nMax && fX !== 0 && delta > tol) {
  128. delta = (b - a) / 2;
  129. x = a + delta;
  130. fX = f(x);
  131. // Update low and high for next search interval.
  132. if (fA * fX > 0) {
  133. a = x;
  134. }
  135. else {
  136. b = x;
  137. }
  138. }
  139. }
  140. return x;
  141. }
  142. /**
  143. * Uses the bisection method to make a best guess of the ideal distance
  144. * between two circles too get the desired overlap.
  145. * Currently there is no known formula to calculate the distance from the
  146. * area of overlap, which makes the bisection method preferred.
  147. * @private
  148. * @param {number} r1
  149. * Radius of the first circle.
  150. * @param {number} r2
  151. * Radiues of the second circle.
  152. * @param {number} overlap
  153. * The wanted overlap between the two circles.
  154. * @return {number}
  155. * Returns the distance needed to get the wanted overlap between the two
  156. * circles.
  157. */
  158. function getDistanceBetweenCirclesByOverlap(r1, r2, overlap) {
  159. var maxDistance = r1 + r2, distance;
  160. if (overlap <= 0) {
  161. // If overlap is below or equal to zero, then there is no overlap.
  162. distance = maxDistance;
  163. }
  164. else if (getAreaOfCircle(r1 < r2 ? r1 : r2) <= overlap) {
  165. // When area of overlap is larger than the area of the smallest
  166. // circle, then it is completely overlapping.
  167. distance = 0;
  168. }
  169. else {
  170. distance = bisect(function (x) {
  171. var actualOverlap = getOverlapBetweenCirclesByDistance(r1, r2, x);
  172. // Return the differance between wanted and actual overlap.
  173. return overlap - actualOverlap;
  174. }, 0, maxDistance);
  175. }
  176. return distance;
  177. }
  178. VennUtils.getDistanceBetweenCirclesByOverlap = getDistanceBetweenCirclesByOverlap;
  179. /**
  180. * Finds the available width for a label, by taking the label position and
  181. * finding the largest distance, which is inside all internal circles, and
  182. * outside all external circles.
  183. *
  184. * @private
  185. * @param {Highcharts.PositionObject} pos
  186. * The x and y coordinate of the label.
  187. * @param {Array<Highcharts.CircleObject>} internal
  188. * Internal circles.
  189. * @param {Array<Highcharts.CircleObject>} external
  190. * External circles.
  191. * @return {number}
  192. * Returns available width for the label.
  193. */
  194. function getLabelWidth(pos, internal, external) {
  195. var radius = internal.reduce(function (min, circle) {
  196. return Math.min(circle.r, min);
  197. }, Infinity),
  198. // Filter out external circles that are completely overlapping.
  199. filteredExternals = external.filter(function (circle) {
  200. return !isPointInsideCircle(pos, circle);
  201. });
  202. var findDistance = function (maxDistance, direction) {
  203. return bisect(function (x) {
  204. var testPos = {
  205. x: pos.x + (direction * x),
  206. y: pos.y
  207. }, isValid = (isPointInsideAllCircles(testPos, internal) &&
  208. isPointOutsideAllCircles(testPos, filteredExternals));
  209. // If the position is valid, then we want to move towards the
  210. // max distance. If not, then we want to away from the max
  211. // distance.
  212. return -(maxDistance - x) + (isValid ? 0 : Number.MAX_VALUE);
  213. }, 0, maxDistance);
  214. };
  215. // Find the smallest distance of left and right.
  216. return Math.min(findDistance(radius, -1), findDistance(radius, 1)) * 2;
  217. }
  218. VennUtils.getLabelWidth = getLabelWidth;
  219. /**
  220. * Calculates a margin for a point based on the iternal and external
  221. * circles. The margin describes if the point is well placed within the
  222. * internal circles, and away from the external.
  223. * @private
  224. * @todo add unit tests.
  225. * @param {Highcharts.PositionObject} point
  226. * The point to evaluate.
  227. * @param {Array<Highcharts.CircleObject>} internal
  228. * The internal circles.
  229. * @param {Array<Highcharts.CircleObject>} external
  230. * The external circles.
  231. * @return {number}
  232. * Returns the margin.
  233. */
  234. function getMarginFromCircles(point, internal, external) {
  235. var margin = internal.reduce(function (margin, circle) {
  236. var m = circle.r - getDistanceBetweenPoints(point, circle);
  237. return (m <= margin) ? m : margin;
  238. }, Number.MAX_VALUE);
  239. margin = external.reduce(function (margin, circle) {
  240. var m = getDistanceBetweenPoints(point, circle) - circle.r;
  241. return (m <= margin) ? m : margin;
  242. }, margin);
  243. return margin;
  244. }
  245. VennUtils.getMarginFromCircles = getMarginFromCircles;
  246. /**
  247. * Calculates the area of overlap between a list of circles.
  248. * @private
  249. * @todo add support for calculating overlap between more than 2 circles.
  250. * @param {Array<Highcharts.CircleObject>} circles
  251. * List of circles with their given positions.
  252. * @return {number}
  253. * Returns the area of overlap between all the circles.
  254. */
  255. function getOverlapBetweenCircles(circles) {
  256. var overlap = 0;
  257. // When there is only two circles we can find the overlap by using their
  258. // radiuses and the distance between them.
  259. if (circles.length === 2) {
  260. var circle1 = circles[0];
  261. var circle2 = circles[1];
  262. overlap = getOverlapBetweenCirclesByDistance(circle1.r, circle2.r, getDistanceBetweenPoints(circle1, circle2));
  263. }
  264. return overlap;
  265. }
  266. // eslint-disable-next-line require-jsdoc
  267. function isSet(x) {
  268. return isArray(x.sets) && x.sets.length === 1;
  269. }
  270. VennUtils.isSet = isSet;
  271. // eslint-disable-next-line require-jsdoc
  272. function isValidRelation(x) {
  273. var map = {};
  274. return (isObject(x) &&
  275. (isNumber(x.value) && x.value > -1) &&
  276. (isArray(x.sets) && x.sets.length > 0) &&
  277. !x.sets.some(function (set) {
  278. var invalid = false;
  279. if (!map[set] && isString(set)) {
  280. map[set] = true;
  281. }
  282. else {
  283. invalid = true;
  284. }
  285. return invalid;
  286. }));
  287. }
  288. // eslint-disable-next-line require-jsdoc
  289. function isValidSet(x) {
  290. return (isValidRelation(x) && isSet(x) && x.value > 0);
  291. }
  292. /**
  293. * Uses a greedy approach to position all the sets. Works well with a small
  294. * number of sets, and are in these cases a good choice aesthetically.
  295. * @private
  296. * @param {Array<object>} relations List of the overlap between two or more
  297. * sets, or the size of a single set.
  298. * @return {Array<object>} List of circles and their calculated positions.
  299. */
  300. function layoutGreedyVenn(relations) {
  301. var positionedSets = [], mapOfIdToCircles = {};
  302. // Define a circle for each set.
  303. relations
  304. .filter(function (relation) {
  305. return relation.sets.length === 1;
  306. }).forEach(function (relation) {
  307. mapOfIdToCircles[relation.sets[0]] = relation.circle = {
  308. x: Number.MAX_VALUE,
  309. y: Number.MAX_VALUE,
  310. r: Math.sqrt(relation.value / Math.PI)
  311. };
  312. });
  313. /**
  314. * Takes a set and updates the position, and add the set to the list of
  315. * positioned sets.
  316. * @private
  317. * @param {object} set
  318. * The set to add to its final position.
  319. * @param {object} coordinates
  320. * The coordinates to position the set at.
  321. * @return {void}
  322. */
  323. var positionSet = function positionSet(set, coordinates) {
  324. var circle = set.circle;
  325. circle.x = coordinates.x;
  326. circle.y = coordinates.y;
  327. positionedSets.push(set);
  328. };
  329. // Find overlap between sets. Ignore relations with more then 2 sets.
  330. addOverlapToSets(relations);
  331. // Sort sets by the sum of their size from large to small.
  332. var sortedByOverlap = relations
  333. .filter(isSet)
  334. .sort(sortByTotalOverlap);
  335. // Position the most overlapped set at 0,0.
  336. positionSet(sortedByOverlap.shift(), { x: 0, y: 0 });
  337. var relationsWithTwoSets = relations.filter(function (x) {
  338. return x.sets.length === 2;
  339. });
  340. // Iterate and position the remaining sets.
  341. sortedByOverlap.forEach(function (set) {
  342. var circle = set.circle, radius = circle.r, overlapping = set.overlapping;
  343. var bestPosition = positionedSets
  344. .reduce(function (best, positionedSet, i) {
  345. var positionedCircle = positionedSet.circle, overlap = overlapping[positionedSet.sets[0]];
  346. // Calculate the distance between the sets to get the
  347. // correct overlap
  348. var distance = getDistanceBetweenCirclesByOverlap(radius, positionedCircle.r, overlap);
  349. // Create a list of possible coordinates calculated from
  350. // distance.
  351. var possibleCoordinates = [
  352. { x: positionedCircle.x + distance, y: positionedCircle.y },
  353. { x: positionedCircle.x - distance, y: positionedCircle.y },
  354. { x: positionedCircle.x, y: positionedCircle.y + distance },
  355. { x: positionedCircle.x, y: positionedCircle.y - distance }
  356. ];
  357. // If there are more circles overlapping, then add the
  358. // intersection points as possible positions.
  359. positionedSets.slice(i + 1).forEach(function (positionedSet2) {
  360. var positionedCircle2 = positionedSet2.circle, overlap2 = overlapping[positionedSet2.sets[0]], distance2 = getDistanceBetweenCirclesByOverlap(radius, positionedCircle2.r, overlap2);
  361. // Add intersections to list of coordinates.
  362. possibleCoordinates = possibleCoordinates.concat(getCircleCircleIntersection({
  363. x: positionedCircle.x,
  364. y: positionedCircle.y,
  365. r: distance
  366. }, {
  367. x: positionedCircle2.x,
  368. y: positionedCircle2.y,
  369. r: distance2
  370. }));
  371. });
  372. // Iterate all suggested coordinates and find the best one.
  373. possibleCoordinates.forEach(function (coordinates) {
  374. circle.x = coordinates.x;
  375. circle.y = coordinates.y;
  376. // Calculate loss for the suggested coordinates.
  377. var currentLoss = loss(mapOfIdToCircles, relationsWithTwoSets);
  378. // If the loss is better, then use these new coordinates
  379. if (currentLoss < best.loss) {
  380. best.loss = currentLoss;
  381. best.coordinates = coordinates;
  382. }
  383. });
  384. // Return resulting coordinates.
  385. return best;
  386. }, {
  387. loss: Number.MAX_VALUE,
  388. coordinates: void 0
  389. });
  390. // Add the set to its final position.
  391. positionSet(set, bestPosition.coordinates);
  392. });
  393. // Return the positions of each set.
  394. return mapOfIdToCircles;
  395. }
  396. VennUtils.layoutGreedyVenn = layoutGreedyVenn;
  397. /**
  398. * Calculates the difference between the desired overlap and the actual
  399. * overlap between two circles.
  400. * @private
  401. * @param {Dictionary<Highcharts.CircleObject>} mapOfIdToCircle
  402. * Map from id to circle.
  403. * @param {Array<Highcharts.VennRelationObject>} relations
  404. * List of relations to calculate the loss of.
  405. * @return {number}
  406. * Returns the loss between positions of the circles for the given
  407. * relations.
  408. */
  409. function loss(mapOfIdToCircle, relations) {
  410. var precision = 10e10;
  411. // Iterate all the relations and calculate their individual loss.
  412. return relations.reduce(function (totalLoss, relation) {
  413. var loss = 0;
  414. if (relation.sets.length > 1) {
  415. var wantedOverlap = relation.value;
  416. // Calculate the actual overlap between the sets.
  417. var actualOverlap = getOverlapBetweenCircles(
  418. // Get the circles for the given sets.
  419. relation.sets.map(function (set) {
  420. return mapOfIdToCircle[set];
  421. }));
  422. var diff = wantedOverlap - actualOverlap;
  423. loss = Math.round((diff * diff) * precision) / precision;
  424. }
  425. // Add calculated loss to the sum.
  426. return totalLoss + loss;
  427. }, 0);
  428. }
  429. VennUtils.loss = loss;
  430. /**
  431. * Prepares the venn data so that it is usable for the layout function.
  432. * Filter out sets, or intersections that includes sets, that are missing in
  433. * the data or has (value < 1). Adds missing relations between sets in the
  434. * data as value = 0.
  435. * @private
  436. * @param {Array<object>} data The raw input data.
  437. * @return {Array<object>} Returns an array of valid venn data.
  438. */
  439. function processVennData(data) {
  440. var d = isArray(data) ? data : [];
  441. var validSets = d
  442. .reduce(function (arr, x) {
  443. // Check if x is a valid set, and that it is not an duplicate.
  444. if (isValidSet(x) && arr.indexOf(x.sets[0]) === -1) {
  445. arr.push(x.sets[0]);
  446. }
  447. return arr;
  448. }, [])
  449. .sort();
  450. var mapOfIdToRelation = d.reduce(function (mapOfIdToRelation, relation) {
  451. if (isValidRelation(relation) &&
  452. !relation.sets.some(function (set) {
  453. return validSets.indexOf(set) === -1;
  454. })) {
  455. mapOfIdToRelation[relation.sets.sort().join()] =
  456. relation;
  457. }
  458. return mapOfIdToRelation;
  459. }, {});
  460. validSets.reduce(function (combinations, set, i, arr) {
  461. var remaining = arr.slice(i + 1);
  462. remaining.forEach(function (set2) {
  463. combinations.push(set + ',' + set2);
  464. });
  465. return combinations;
  466. }, []).forEach(function (combination) {
  467. if (!mapOfIdToRelation[combination]) {
  468. var obj = {
  469. sets: combination.split(','),
  470. value: 0
  471. };
  472. mapOfIdToRelation[combination] = obj;
  473. }
  474. });
  475. // Transform map into array.
  476. return Object
  477. .keys(mapOfIdToRelation)
  478. .map(function (id) {
  479. return mapOfIdToRelation[id];
  480. });
  481. }
  482. VennUtils.processVennData = processVennData;
  483. /**
  484. * Takes two sets and finds the one with the largest total overlap.
  485. * @private
  486. * @param {object} a The first set to compare.
  487. * @param {object} b The second set to compare.
  488. * @return {number} Returns 0 if a and b are equal, <0 if a is greater, >0 if b
  489. * is greater.
  490. */
  491. function sortByTotalOverlap(a, b) {
  492. return b.totalOverlap - a.totalOverlap;
  493. }
  494. VennUtils.sortByTotalOverlap = sortByTotalOverlap;
  495. })(VennUtils || (VennUtils = {}));
  496. /* *
  497. *
  498. * Default Export
  499. *
  500. * */
  501. export default VennUtils;