VennSeries.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  1. /* *
  2. *
  3. * Experimental Highcharts module which enables visualization of a Venn
  4. * diagram.
  5. *
  6. * (c) 2016-2020 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. 'use strict';
  18. import Color from '../Core/Color.js';
  19. var color = Color.parse;
  20. import H from '../Core/Globals.js';
  21. import U from '../Core/Utilities.js';
  22. var addEvent = U.addEvent, animObject = U.animObject, extend = U.extend, isArray = U.isArray, isNumber = U.isNumber, isObject = U.isObject, isString = U.isString, merge = U.merge, seriesType = U.seriesType;
  23. import drawPointModule from '../Mixins/DrawPoint.js';
  24. var draw = drawPointModule.draw;
  25. import geometry from '../Mixins/Geometry.js';
  26. import geometryCirclesModule from '../Mixins/GeometryCircles.js';
  27. var getAreaOfCircle = geometryCirclesModule.getAreaOfCircle, getAreaOfIntersectionBetweenCircles = geometryCirclesModule.getAreaOfIntersectionBetweenCircles, getCircleCircleIntersection = geometryCirclesModule.getCircleCircleIntersection, getCirclesIntersectionPolygon = geometryCirclesModule.getCirclesIntersectionPolygon, getOverlapBetweenCirclesByDistance = geometryCirclesModule.getOverlapBetweenCircles, isCircle1CompletelyOverlappingCircle2 = geometryCirclesModule.isCircle1CompletelyOverlappingCircle2, isPointInsideAllCircles = geometryCirclesModule.isPointInsideAllCircles, isPointInsideCircle = geometryCirclesModule.isPointInsideCircle, isPointOutsideAllCircles = geometryCirclesModule.isPointOutsideAllCircles;
  28. import nelderMeadMixin from '../Mixins/NelderMead.js';
  29. var nelderMead = nelderMeadMixin.nelderMead;
  30. import '../Core/Series/Series.js';
  31. var getCenterOfPoints = geometry.getCenterOfPoints, getDistanceBetweenPoints = geometry.getDistanceBetweenPoints, seriesTypes = H.seriesTypes;
  32. var objectValues = function objectValues(obj) {
  33. return Object.keys(obj).map(function (x) {
  34. return obj[x];
  35. });
  36. };
  37. /**
  38. * Calculates the area of overlap between a list of circles.
  39. * @private
  40. * @todo add support for calculating overlap between more than 2 circles.
  41. * @param {Array<Highcharts.CircleObject>} circles
  42. * List of circles with their given positions.
  43. * @return {number}
  44. * Returns the area of overlap between all the circles.
  45. */
  46. var getOverlapBetweenCircles = function getOverlapBetweenCircles(circles) {
  47. var overlap = 0;
  48. // When there is only two circles we can find the overlap by using their
  49. // radiuses and the distance between them.
  50. if (circles.length === 2) {
  51. var circle1 = circles[0];
  52. var circle2 = circles[1];
  53. overlap = getOverlapBetweenCirclesByDistance(circle1.r, circle2.r, getDistanceBetweenPoints(circle1, circle2));
  54. }
  55. return overlap;
  56. };
  57. /**
  58. * Calculates the difference between the desired overlap and the actual overlap
  59. * between two circles.
  60. * @private
  61. * @param {Dictionary<Highcharts.CircleObject>} mapOfIdToCircle
  62. * Map from id to circle.
  63. * @param {Array<Highcharts.VennRelationObject>} relations
  64. * List of relations to calculate the loss of.
  65. * @return {number}
  66. * Returns the loss between positions of the circles for the given relations.
  67. */
  68. var loss = function loss(mapOfIdToCircle, relations) {
  69. var precision = 10e10;
  70. // Iterate all the relations and calculate their individual loss.
  71. return relations.reduce(function (totalLoss, relation) {
  72. var loss = 0;
  73. if (relation.sets.length > 1) {
  74. var wantedOverlap = relation.value;
  75. // Calculate the actual overlap between the sets.
  76. var actualOverlap = getOverlapBetweenCircles(
  77. // Get the circles for the given sets.
  78. relation.sets.map(function (set) {
  79. return mapOfIdToCircle[set];
  80. }));
  81. var diff = wantedOverlap - actualOverlap;
  82. loss = Math.round((diff * diff) * precision) / precision;
  83. }
  84. // Add calculated loss to the sum.
  85. return totalLoss + loss;
  86. }, 0);
  87. };
  88. /**
  89. * Finds the root of a given function. The root is the input value needed for
  90. * a function to return 0.
  91. *
  92. * See https://en.wikipedia.org/wiki/Bisection_method#Algorithm
  93. *
  94. * TODO: Add unit tests.
  95. *
  96. * @param {Function} f
  97. * The function to find the root of.
  98. * @param {number} a
  99. * The lowest number in the search range.
  100. * @param {number} b
  101. * The highest number in the search range.
  102. * @param {number} [tolerance=1e-10]
  103. * The allowed difference between the returned value and root.
  104. * @param {number} [maxIterations=100]
  105. * The maximum iterations allowed.
  106. * @return {number}
  107. * Root number.
  108. */
  109. var bisect = function bisect(f, a, b, tolerance, maxIterations) {
  110. var fA = f(a), fB = f(b), nMax = maxIterations || 100, tol = tolerance || 1e-10, delta = b - a, n = 1, x, fX;
  111. if (a >= b) {
  112. throw new Error('a must be smaller than b.');
  113. }
  114. else if (fA * fB > 0) {
  115. throw new Error('f(a) and f(b) must have opposite signs.');
  116. }
  117. if (fA === 0) {
  118. x = a;
  119. }
  120. else if (fB === 0) {
  121. x = b;
  122. }
  123. else {
  124. while (n++ <= nMax && fX !== 0 && delta > tol) {
  125. delta = (b - a) / 2;
  126. x = a + delta;
  127. fX = f(x);
  128. // Update low and high for next search interval.
  129. if (fA * fX > 0) {
  130. a = x;
  131. }
  132. else {
  133. b = x;
  134. }
  135. }
  136. }
  137. return x;
  138. };
  139. /**
  140. * Uses the bisection method to make a best guess of the ideal distance between
  141. * two circles too get the desired overlap.
  142. * Currently there is no known formula to calculate the distance from the area
  143. * of overlap, which makes the bisection method preferred.
  144. * @private
  145. * @param {number} r1
  146. * Radius of the first circle.
  147. * @param {number} r2
  148. * Radiues of the second circle.
  149. * @param {number} overlap
  150. * The wanted overlap between the two circles.
  151. * @return {number}
  152. * Returns the distance needed to get the wanted overlap between the two
  153. * circles.
  154. */
  155. var getDistanceBetweenCirclesByOverlap = function getDistanceBetweenCirclesByOverlap(r1, r2, overlap) {
  156. var maxDistance = r1 + r2, distance;
  157. if (overlap <= 0) {
  158. // If overlap is below or equal to zero, then there is no overlap.
  159. distance = maxDistance;
  160. }
  161. else if (getAreaOfCircle(r1 < r2 ? r1 : r2) <= overlap) {
  162. // When area of overlap is larger than the area of the smallest circle,
  163. // then it is completely overlapping.
  164. distance = 0;
  165. }
  166. else {
  167. distance = bisect(function (x) {
  168. var actualOverlap = getOverlapBetweenCirclesByDistance(r1, r2, x);
  169. // Return the differance between wanted and actual overlap.
  170. return overlap - actualOverlap;
  171. }, 0, maxDistance);
  172. }
  173. return distance;
  174. };
  175. var isSet = function (x) {
  176. return isArray(x.sets) && x.sets.length === 1;
  177. };
  178. /**
  179. * Calculates a margin for a point based on the iternal and external circles.
  180. * The margin describes if the point is well placed within the internal circles,
  181. * and away from the external
  182. * @private
  183. * @todo add unit tests.
  184. * @param {Highcharts.PositionObject} point
  185. * The point to evaluate.
  186. * @param {Array<Highcharts.CircleObject>} internal
  187. * The internal circles.
  188. * @param {Array<Highcharts.CircleObject>} external
  189. * The external circles.
  190. * @return {number}
  191. * Returns the margin.
  192. */
  193. var getMarginFromCircles = function getMarginFromCircles(point, internal, external) {
  194. var margin = internal.reduce(function (margin, circle) {
  195. var m = circle.r - getDistanceBetweenPoints(point, circle);
  196. return (m <= margin) ? m : margin;
  197. }, Number.MAX_VALUE);
  198. margin = external.reduce(function (margin, circle) {
  199. var m = getDistanceBetweenPoints(point, circle) - circle.r;
  200. return (m <= margin) ? m : margin;
  201. }, margin);
  202. return margin;
  203. };
  204. /**
  205. * Finds the optimal label position by looking for a position that has a low
  206. * distance from the internal circles, and as large possible distane to the
  207. * external circles.
  208. * @private
  209. * @todo Optimize the intial position.
  210. * @todo Add unit tests.
  211. * @param {Array<Highcharts.CircleObject>} internal
  212. * Internal circles.
  213. * @param {Array<Highcharts.CircleObject>} external
  214. * External circles.
  215. * @return {Highcharts.PositionObject}
  216. * Returns the found position.
  217. */
  218. var getLabelPosition = function getLabelPosition(internal, external) {
  219. // Get the best label position within the internal circles.
  220. var best = internal.reduce(function (best, circle) {
  221. var d = circle.r / 2;
  222. // Give a set of points with the circle to evaluate as the best label
  223. // position.
  224. return [
  225. { x: circle.x, y: circle.y },
  226. { x: circle.x + d, y: circle.y },
  227. { x: circle.x - d, y: circle.y },
  228. { x: circle.x, y: circle.y + d },
  229. { x: circle.x, y: circle.y - d }
  230. ]
  231. // Iterate the given points and return the one with the largest
  232. // margin.
  233. .reduce(function (best, point) {
  234. var margin = getMarginFromCircles(point, internal, external);
  235. // If the margin better than the current best, then update best.
  236. if (best.margin < margin) {
  237. best.point = point;
  238. best.margin = margin;
  239. }
  240. return best;
  241. }, best);
  242. }, {
  243. point: void 0,
  244. margin: -Number.MAX_VALUE
  245. }).point;
  246. // Use nelder mead to optimize the initial label position.
  247. var optimal = nelderMead(function (p) {
  248. return -(getMarginFromCircles({ x: p[0], y: p[1] }, internal, external));
  249. }, [best.x, best.y]);
  250. // Update best to be the point which was found to have the best margin.
  251. best = {
  252. x: optimal[0],
  253. y: optimal[1]
  254. };
  255. if (!(isPointInsideAllCircles(best, internal) &&
  256. isPointOutsideAllCircles(best, external))) {
  257. // If point was either outside one of the internal, or inside one of the
  258. // external, then it was invalid and should use a fallback.
  259. if (internal.length > 1) {
  260. best = getCenterOfPoints(getCirclesIntersectionPolygon(internal));
  261. }
  262. else {
  263. best = {
  264. x: internal[0].x,
  265. y: internal[0].y
  266. };
  267. }
  268. }
  269. // Return the best point.
  270. return best;
  271. };
  272. /**
  273. * Finds the available width for a label, by taking the label position and
  274. * finding the largest distance, which is inside all internal circles, and
  275. * outside all external circles.
  276. *
  277. * @private
  278. * @param {Highcharts.PositionObject} pos
  279. * The x and y coordinate of the label.
  280. * @param {Array<Highcharts.CircleObject>} internal
  281. * Internal circles.
  282. * @param {Array<Highcharts.CircleObject>} external
  283. * External circles.
  284. * @return {number}
  285. * Returns available width for the label.
  286. */
  287. var getLabelWidth = function getLabelWidth(pos, internal, external) {
  288. var radius = internal.reduce(function (min, circle) {
  289. return Math.min(circle.r, min);
  290. }, Infinity),
  291. // Filter out external circles that are completely overlapping.
  292. filteredExternals = external.filter(function (circle) {
  293. return !isPointInsideCircle(pos, circle);
  294. });
  295. var findDistance = function (maxDistance, direction) {
  296. return bisect(function (x) {
  297. var testPos = {
  298. x: pos.x + (direction * x),
  299. y: pos.y
  300. }, isValid = (isPointInsideAllCircles(testPos, internal) &&
  301. isPointOutsideAllCircles(testPos, filteredExternals));
  302. // If the position is valid, then we want to move towards the max
  303. // distance. If not, then we want to away from the max distance.
  304. return -(maxDistance - x) + (isValid ? 0 : Number.MAX_VALUE);
  305. }, 0, maxDistance);
  306. };
  307. // Find the smallest distance of left and right.
  308. return Math.min(findDistance(radius, -1), findDistance(radius, 1)) * 2;
  309. };
  310. /**
  311. * Calulates data label values for a given relations object.
  312. *
  313. * @private
  314. * @todo add unit tests
  315. * @param {Highcharts.VennRelationObject} relation A relations object.
  316. * @param {Array<Highcharts.VennRelationObject>} setRelations The list of
  317. * relations that is a set.
  318. * @return {Highcharts.VennLabelValuesObject}
  319. * Returns an object containing position and width of the label.
  320. */
  321. function getLabelValues(relation, setRelations) {
  322. var sets = relation.sets;
  323. // Create a list of internal and external circles.
  324. var data = setRelations.reduce(function (data, set) {
  325. // If the set exists in this relation, then it is internal,
  326. // otherwise it will be external.
  327. var isInternal = sets.indexOf(set.sets[0]) > -1;
  328. var property = isInternal ? 'internal' : 'external';
  329. // Add the circle to the list.
  330. data[property].push(set.circle);
  331. return data;
  332. }, {
  333. internal: [],
  334. external: []
  335. });
  336. // Filter out external circles that are completely overlapping all internal
  337. data.external = data.external.filter(function (externalCircle) {
  338. return data.internal.some(function (internalCircle) {
  339. return !isCircle1CompletelyOverlappingCircle2(externalCircle, internalCircle);
  340. });
  341. });
  342. // Calulate the label position.
  343. var position = getLabelPosition(data.internal, data.external);
  344. // Calculate the label width
  345. var width = getLabelWidth(position, data.internal, data.external);
  346. return {
  347. position: position,
  348. width: width
  349. };
  350. }
  351. /**
  352. * Takes an array of relations and adds the properties `totalOverlap` and
  353. * `overlapping` to each set. The property `totalOverlap` is the sum of value
  354. * for each relation where this set is included. The property `overlapping` is
  355. * a map of how much this set is overlapping another set.
  356. * NOTE: This algorithm ignores relations consisting of more than 2 sets.
  357. * @private
  358. * @param {Array<Highcharts.VennRelationObject>} relations
  359. * The list of relations that should be sorted.
  360. * @return {Array<Highcharts.VennRelationObject>}
  361. * Returns the modified input relations with added properties `totalOverlap` and
  362. * `overlapping`.
  363. */
  364. var addOverlapToSets = function addOverlapToSets(relations) {
  365. // Calculate the amount of overlap per set.
  366. var mapOfIdToProps = relations
  367. // Filter out relations consisting of 2 sets.
  368. .filter(function (relation) {
  369. return relation.sets.length === 2;
  370. })
  371. // Sum up the amount of overlap for each set.
  372. .reduce(function (map, relation) {
  373. var sets = relation.sets;
  374. sets.forEach(function (set, i, arr) {
  375. if (!isObject(map[set])) {
  376. map[set] = {
  377. overlapping: {},
  378. totalOverlap: 0
  379. };
  380. }
  381. map[set].totalOverlap += relation.value;
  382. map[set].overlapping[arr[1 - i]] = relation.value;
  383. });
  384. return map;
  385. }, {});
  386. relations
  387. // Filter out single sets
  388. .filter(isSet)
  389. // Extend the set with the calculated properties.
  390. .forEach(function (set) {
  391. var properties = mapOfIdToProps[set.sets[0]];
  392. extend(set, properties);
  393. });
  394. // Returns the modified relations.
  395. return relations;
  396. };
  397. /**
  398. * Takes two sets and finds the one with the largest total overlap.
  399. * @private
  400. * @param {object} a The first set to compare.
  401. * @param {object} b The second set to compare.
  402. * @return {number} Returns 0 if a and b are equal, <0 if a is greater, >0 if b
  403. * is greater.
  404. */
  405. var sortByTotalOverlap = function sortByTotalOverlap(a, b) {
  406. return b.totalOverlap - a.totalOverlap;
  407. };
  408. /**
  409. * Uses a greedy approach to position all the sets. Works well with a small
  410. * number of sets, and are in these cases a good choice aesthetically.
  411. * @private
  412. * @param {Array<object>} relations List of the overlap between two or more
  413. * sets, or the size of a single set.
  414. * @return {Array<object>} List of circles and their calculated positions.
  415. */
  416. var layoutGreedyVenn = function layoutGreedyVenn(relations) {
  417. var positionedSets = [], mapOfIdToCircles = {};
  418. // Define a circle for each set.
  419. relations
  420. .filter(function (relation) {
  421. return relation.sets.length === 1;
  422. }).forEach(function (relation) {
  423. mapOfIdToCircles[relation.sets[0]] = relation.circle = {
  424. x: Number.MAX_VALUE,
  425. y: Number.MAX_VALUE,
  426. r: Math.sqrt(relation.value / Math.PI)
  427. };
  428. });
  429. /**
  430. * Takes a set and updates the position, and add the set to the list of
  431. * positioned sets.
  432. * @private
  433. * @param {object} set
  434. * The set to add to its final position.
  435. * @param {object} coordinates
  436. * The coordinates to position the set at.
  437. * @return {void}
  438. */
  439. var positionSet = function positionSet(set, coordinates) {
  440. var circle = set.circle;
  441. circle.x = coordinates.x;
  442. circle.y = coordinates.y;
  443. positionedSets.push(set);
  444. };
  445. // Find overlap between sets. Ignore relations with more then 2 sets.
  446. addOverlapToSets(relations);
  447. // Sort sets by the sum of their size from large to small.
  448. var sortedByOverlap = relations
  449. .filter(isSet)
  450. .sort(sortByTotalOverlap);
  451. // Position the most overlapped set at 0,0.
  452. positionSet(sortedByOverlap.shift(), { x: 0, y: 0 });
  453. var relationsWithTwoSets = relations.filter(function (x) {
  454. return x.sets.length === 2;
  455. });
  456. // Iterate and position the remaining sets.
  457. sortedByOverlap.forEach(function (set) {
  458. var circle = set.circle, radius = circle.r, overlapping = set.overlapping;
  459. var bestPosition = positionedSets
  460. .reduce(function (best, positionedSet, i) {
  461. var positionedCircle = positionedSet.circle, overlap = overlapping[positionedSet.sets[0]];
  462. // Calculate the distance between the sets to get the correct
  463. // overlap
  464. var distance = getDistanceBetweenCirclesByOverlap(radius, positionedCircle.r, overlap);
  465. // Create a list of possible coordinates calculated from
  466. // distance.
  467. var possibleCoordinates = [
  468. { x: positionedCircle.x + distance, y: positionedCircle.y },
  469. { x: positionedCircle.x - distance, y: positionedCircle.y },
  470. { x: positionedCircle.x, y: positionedCircle.y + distance },
  471. { x: positionedCircle.x, y: positionedCircle.y - distance }
  472. ];
  473. // If there are more circles overlapping, then add the
  474. // intersection points as possible positions.
  475. positionedSets.slice(i + 1).forEach(function (positionedSet2) {
  476. var positionedCircle2 = positionedSet2.circle, overlap2 = overlapping[positionedSet2.sets[0]], distance2 = getDistanceBetweenCirclesByOverlap(radius, positionedCircle2.r, overlap2);
  477. // Add intersections to list of coordinates.
  478. possibleCoordinates = possibleCoordinates.concat(getCircleCircleIntersection({
  479. x: positionedCircle.x,
  480. y: positionedCircle.y,
  481. r: distance
  482. }, {
  483. x: positionedCircle2.x,
  484. y: positionedCircle2.y,
  485. r: distance2
  486. }));
  487. });
  488. // Iterate all suggested coordinates and find the best one.
  489. possibleCoordinates.forEach(function (coordinates) {
  490. circle.x = coordinates.x;
  491. circle.y = coordinates.y;
  492. // Calculate loss for the suggested coordinates.
  493. var currentLoss = loss(mapOfIdToCircles, relationsWithTwoSets);
  494. // If the loss is better, then use these new coordinates.
  495. if (currentLoss < best.loss) {
  496. best.loss = currentLoss;
  497. best.coordinates = coordinates;
  498. }
  499. });
  500. // Return resulting coordinates.
  501. return best;
  502. }, {
  503. loss: Number.MAX_VALUE,
  504. coordinates: void 0
  505. });
  506. // Add the set to its final position.
  507. positionSet(set, bestPosition.coordinates);
  508. });
  509. // Return the positions of each set.
  510. return mapOfIdToCircles;
  511. };
  512. /**
  513. * Calculates the positions, and the label values of all the sets in the venn
  514. * diagram.
  515. *
  516. * @private
  517. * @todo Add support for constrained MDS.
  518. * @param {Array<Highchats.VennRelationObject>} relations
  519. * List of the overlap between two or more sets, or the size of a single set.
  520. * @return {Highcharts.Dictionary<*>}
  521. * List of circles and their calculated positions.
  522. */
  523. function layout(relations) {
  524. var mapOfIdToShape = {};
  525. var mapOfIdToLabelValues = {};
  526. // Calculate best initial positions by using greedy layout.
  527. if (relations.length > 0) {
  528. var mapOfIdToCircles_1 = layoutGreedyVenn(relations);
  529. var setRelations_1 = relations.filter(isSet);
  530. relations
  531. .forEach(function (relation) {
  532. var sets = relation.sets;
  533. var id = sets.join();
  534. // Get shape from map of circles, or calculate intersection.
  535. var shape = isSet(relation) ?
  536. mapOfIdToCircles_1[id] :
  537. getAreaOfIntersectionBetweenCircles(sets.map(function (set) {
  538. return mapOfIdToCircles_1[set];
  539. }));
  540. // Calculate label values if the set has a shape
  541. if (shape) {
  542. mapOfIdToShape[id] = shape;
  543. mapOfIdToLabelValues[id] = getLabelValues(relation, setRelations_1);
  544. }
  545. });
  546. }
  547. return { mapOfIdToShape: mapOfIdToShape, mapOfIdToLabelValues: mapOfIdToLabelValues };
  548. }
  549. var isValidRelation = function (x) {
  550. var map = {};
  551. return (isObject(x) &&
  552. (isNumber(x.value) && x.value > -1) &&
  553. (isArray(x.sets) && x.sets.length > 0) &&
  554. !x.sets.some(function (set) {
  555. var invalid = false;
  556. if (!map[set] && isString(set)) {
  557. map[set] = true;
  558. }
  559. else {
  560. invalid = true;
  561. }
  562. return invalid;
  563. }));
  564. };
  565. var isValidSet = function (x) {
  566. return (isValidRelation(x) && isSet(x) && x.value > 0);
  567. };
  568. /**
  569. * Prepares the venn data so that it is usable for the layout function. Filter
  570. * out sets, or intersections that includes sets, that are missing in the data
  571. * or has (value < 1). Adds missing relations between sets in the data as
  572. * value = 0.
  573. * @private
  574. * @param {Array<object>} data The raw input data.
  575. * @return {Array<object>} Returns an array of valid venn data.
  576. */
  577. var processVennData = function processVennData(data) {
  578. var d = isArray(data) ? data : [];
  579. var validSets = d
  580. .reduce(function (arr, x) {
  581. // Check if x is a valid set, and that it is not an duplicate.
  582. if (isValidSet(x) && arr.indexOf(x.sets[0]) === -1) {
  583. arr.push(x.sets[0]);
  584. }
  585. return arr;
  586. }, [])
  587. .sort();
  588. var mapOfIdToRelation = d.reduce(function (mapOfIdToRelation, relation) {
  589. if (isValidRelation(relation) &&
  590. !relation.sets.some(function (set) {
  591. return validSets.indexOf(set) === -1;
  592. })) {
  593. mapOfIdToRelation[relation.sets.sort().join()] =
  594. relation;
  595. }
  596. return mapOfIdToRelation;
  597. }, {});
  598. validSets.reduce(function (combinations, set, i, arr) {
  599. var remaining = arr.slice(i + 1);
  600. remaining.forEach(function (set2) {
  601. combinations.push(set + ',' + set2);
  602. });
  603. return combinations;
  604. }, []).forEach(function (combination) {
  605. if (!mapOfIdToRelation[combination]) {
  606. var obj = {
  607. sets: combination.split(','),
  608. value: 0
  609. };
  610. mapOfIdToRelation[combination] = obj;
  611. }
  612. });
  613. // Transform map into array.
  614. return objectValues(mapOfIdToRelation);
  615. };
  616. /**
  617. * Calculates the proper scale to fit the cloud inside the plotting area.
  618. * @private
  619. * @todo add unit test
  620. * @param {number} targetWidth
  621. * Width of target area.
  622. * @param {number} targetHeight
  623. * Height of target area.
  624. * @param {Highcharts.PolygonBoxObject} field
  625. * The playing field.
  626. * @return {Highcharts.Dictionary<number>}
  627. * Returns the value to scale the playing field up to the size of the target
  628. * area, and center of x and y.
  629. */
  630. var getScale = function getScale(targetWidth, targetHeight, field) {
  631. var height = field.bottom - field.top, // top is smaller than bottom
  632. width = field.right - field.left, scaleX = width > 0 ? 1 / width * targetWidth : 1, scaleY = height > 0 ? 1 / height * targetHeight : 1, adjustX = (field.right + field.left) / 2, adjustY = (field.top + field.bottom) / 2, scale = Math.min(scaleX, scaleY);
  633. return {
  634. scale: scale,
  635. centerX: targetWidth / 2 - adjustX * scale,
  636. centerY: targetHeight / 2 - adjustY * scale
  637. };
  638. };
  639. /**
  640. * If a circle is outside a give field, then the boundaries of the field is
  641. * adjusted accordingly. Modifies the field object which is passed as the first
  642. * parameter.
  643. * @private
  644. * @todo NOTE: Copied from wordcloud, can probably be unified.
  645. * @param {Highcharts.PolygonBoxObject} field
  646. * The bounding box of a playing field.
  647. * @param {Highcharts.CircleObject} circle
  648. * The bounding box for a placed point.
  649. * @return {Highcharts.PolygonBoxObject}
  650. * Returns a modified field object.
  651. */
  652. var updateFieldBoundaries = function updateFieldBoundaries(field, circle) {
  653. var left = circle.x - circle.r, right = circle.x + circle.r, bottom = circle.y + circle.r, top = circle.y - circle.r;
  654. // TODO improve type checking.
  655. if (!isNumber(field.left) || field.left > left) {
  656. field.left = left;
  657. }
  658. if (!isNumber(field.right) || field.right < right) {
  659. field.right = right;
  660. }
  661. if (!isNumber(field.top) || field.top > top) {
  662. field.top = top;
  663. }
  664. if (!isNumber(field.bottom) || field.bottom < bottom) {
  665. field.bottom = bottom;
  666. }
  667. return field;
  668. };
  669. /**
  670. * A Venn diagram displays all possible logical relations between a collection
  671. * of different sets. The sets are represented by circles, and the relation
  672. * between the sets are displayed by the overlap or lack of overlap between
  673. * them. The venn diagram is a special case of Euler diagrams, which can also
  674. * be displayed by this series type.
  675. *
  676. * @sample {highcharts} highcharts/demo/venn-diagram/
  677. * Venn diagram
  678. * @sample {highcharts} highcharts/demo/euler-diagram/
  679. * Euler diagram
  680. *
  681. * @extends plotOptions.scatter
  682. * @excluding connectEnds, connectNulls, cropThreshold, dragDrop,
  683. * findNearestPointBy, getExtremesFromAll, jitter, label, linecap,
  684. * lineWidth, linkedTo, marker, negativeColor, pointInterval,
  685. * pointIntervalUnit, pointPlacement, pointStart, softThreshold,
  686. * stacking, steps, threshold, xAxis, yAxis, zoneAxis, zones,
  687. * dataSorting, boostThreshold, boostBlending
  688. * @product highcharts
  689. * @requires modules/venn
  690. * @optionparent plotOptions.venn
  691. */
  692. var vennOptions = {
  693. borderColor: '#cccccc',
  694. borderDashStyle: 'solid',
  695. borderWidth: 1,
  696. brighten: 0,
  697. clip: false,
  698. colorByPoint: true,
  699. dataLabels: {
  700. enabled: true,
  701. verticalAlign: 'middle',
  702. formatter: function () {
  703. return this.point.name;
  704. }
  705. },
  706. /**
  707. * @ignore-option
  708. * @private
  709. */
  710. inactiveOtherPoints: true,
  711. marker: false,
  712. opacity: 0.75,
  713. showInLegend: false,
  714. states: {
  715. /**
  716. * @excluding halo
  717. */
  718. hover: {
  719. opacity: 1,
  720. borderColor: '#333333'
  721. },
  722. /**
  723. * @excluding halo
  724. */
  725. select: {
  726. color: '#cccccc',
  727. borderColor: '#000000',
  728. animation: false
  729. },
  730. inactive: {
  731. opacity: 0.075
  732. }
  733. },
  734. tooltip: {
  735. pointFormat: '{point.name}: {point.value}'
  736. }
  737. };
  738. var vennSeries = {
  739. isCartesian: false,
  740. axisTypes: [],
  741. directTouch: true,
  742. pointArrayMap: ['value'],
  743. init: function () {
  744. seriesTypes.scatter.prototype.init.apply(this, arguments);
  745. // Venn's opacity is a different option from other series
  746. delete this.opacity;
  747. },
  748. translate: function () {
  749. var chart = this.chart;
  750. this.processedXData = this.xData;
  751. this.generatePoints();
  752. // Process the data before passing it into the layout function.
  753. var relations = processVennData(this.options.data);
  754. // Calculate the positions of each circle.
  755. var _a = layout(relations), mapOfIdToShape = _a.mapOfIdToShape, mapOfIdToLabelValues = _a.mapOfIdToLabelValues;
  756. // Calculate the scale, and center of the plot area.
  757. var field = Object.keys(mapOfIdToShape)
  758. .filter(function (key) {
  759. var shape = mapOfIdToShape[key];
  760. return shape && isNumber(shape.r);
  761. })
  762. .reduce(function (field, key) {
  763. return updateFieldBoundaries(field, mapOfIdToShape[key]);
  764. }, { top: 0, bottom: 0, left: 0, right: 0 }), scaling = getScale(chart.plotWidth, chart.plotHeight, field), scale = scaling.scale, centerX = scaling.centerX, centerY = scaling.centerY;
  765. // Iterate all points and calculate and draw their graphics.
  766. this.points.forEach(function (point) {
  767. var sets = isArray(point.sets) ? point.sets : [], id = sets.join(), shape = mapOfIdToShape[id], shapeArgs, dataLabelValues = mapOfIdToLabelValues[id] || {}, dataLabelWidth = dataLabelValues.width, dataLabelPosition = dataLabelValues.position, dlOptions = point.options && point.options.dataLabels;
  768. if (shape) {
  769. if (shape.r) {
  770. shapeArgs = {
  771. x: centerX + shape.x * scale,
  772. y: centerY + shape.y * scale,
  773. r: shape.r * scale
  774. };
  775. }
  776. else if (shape.d) {
  777. var d = shape.d;
  778. d.forEach(function (seg) {
  779. if (seg[0] === 'M') {
  780. seg[1] = centerX + seg[1] * scale;
  781. seg[2] = centerY + seg[2] * scale;
  782. }
  783. else if (seg[0] === 'A') {
  784. seg[1] = seg[1] * scale;
  785. seg[2] = seg[2] * scale;
  786. seg[6] = centerX + seg[6] * scale;
  787. seg[7] = centerY + seg[7] * scale;
  788. }
  789. });
  790. shapeArgs = { d: d };
  791. }
  792. // Scale the position for the data label.
  793. if (dataLabelPosition) {
  794. dataLabelPosition.x = centerX + dataLabelPosition.x * scale;
  795. dataLabelPosition.y = centerY + dataLabelPosition.y * scale;
  796. }
  797. else {
  798. dataLabelPosition = {};
  799. }
  800. if (isNumber(dataLabelWidth)) {
  801. dataLabelWidth = Math.round(dataLabelWidth * scale);
  802. }
  803. }
  804. point.shapeArgs = shapeArgs;
  805. // Placement for the data labels
  806. if (dataLabelPosition && shapeArgs) {
  807. point.plotX = dataLabelPosition.x;
  808. point.plotY = dataLabelPosition.y;
  809. }
  810. // Add width for the data label
  811. if (dataLabelWidth && shapeArgs) {
  812. point.dlOptions = merge(true, {
  813. style: {
  814. width: dataLabelWidth
  815. }
  816. }, isObject(dlOptions) && dlOptions);
  817. }
  818. // Set name for usage in tooltip and in data label.
  819. point.name = point.options.name || sets.join('∩');
  820. });
  821. },
  822. /* eslint-disable valid-jsdoc */
  823. /**
  824. * Draw the graphics for each point.
  825. * @private
  826. */
  827. drawPoints: function () {
  828. var series = this,
  829. // Series properties
  830. chart = series.chart, group = series.group, points = series.points || [],
  831. // Chart properties
  832. renderer = chart.renderer;
  833. // Iterate all points and calculate and draw their graphics.
  834. points.forEach(function (point) {
  835. var attribs = {
  836. zIndex: isArray(point.sets) ? point.sets.length : 0
  837. }, shapeArgs = point.shapeArgs;
  838. // Add point attribs
  839. if (!chart.styledMode) {
  840. extend(attribs, series.pointAttribs(point, point.state));
  841. }
  842. // Draw the point graphic.
  843. point.draw({
  844. isNew: !point.graphic,
  845. animatableAttribs: shapeArgs,
  846. attribs: attribs,
  847. group: group,
  848. renderer: renderer,
  849. shapeType: shapeArgs && shapeArgs.d ? 'path' : 'circle'
  850. });
  851. });
  852. },
  853. /**
  854. * Calculates the style attributes for a point. The attributes can vary
  855. * depending on the state of the point.
  856. * @private
  857. * @param {Highcharts.Point} point
  858. * The point which will get the resulting attributes.
  859. * @param {string} [state]
  860. * The state of the point.
  861. * @return {Highcharts.SVGAttributes}
  862. * Returns the calculated attributes.
  863. */
  864. pointAttribs: function (point, state) {
  865. var series = this, seriesOptions = series.options || {}, pointOptions = point && point.options || {}, stateOptions = (state && seriesOptions.states[state]) || {}, options = merge(seriesOptions, { color: point && point.color }, pointOptions, stateOptions);
  866. // Return resulting values for the attributes.
  867. return {
  868. 'fill': color(options.color)
  869. .setOpacity(options.opacity)
  870. .brighten(options.brightness)
  871. .get(),
  872. 'stroke': options.borderColor,
  873. 'stroke-width': options.borderWidth,
  874. 'dashstyle': options.borderDashStyle
  875. };
  876. },
  877. /* eslint-enable valid-jsdoc */
  878. animate: function (init) {
  879. if (!init) {
  880. var series = this, animOptions = animObject(series.options.animation);
  881. series.points.forEach(function (point) {
  882. var args = point.shapeArgs;
  883. if (point.graphic && args) {
  884. var attr = {}, animate = {};
  885. if (args.d) {
  886. // If shape is a path, then animate opacity.
  887. attr.opacity = 0.001;
  888. }
  889. else {
  890. // If shape is a circle, then animate radius.
  891. attr.r = 0;
  892. animate.r = args.r;
  893. }
  894. point.graphic
  895. .attr(attr)
  896. .animate(animate, animOptions);
  897. // If shape is path, then fade it in after the circles
  898. // animation
  899. if (args.d) {
  900. setTimeout(function () {
  901. if (point && point.graphic) {
  902. point.graphic.animate({
  903. opacity: 1
  904. });
  905. }
  906. }, animOptions.duration);
  907. }
  908. }
  909. }, series);
  910. }
  911. },
  912. utils: {
  913. addOverlapToSets: addOverlapToSets,
  914. geometry: geometry,
  915. geometryCircles: geometryCirclesModule,
  916. getLabelWidth: getLabelWidth,
  917. getMarginFromCircles: getMarginFromCircles,
  918. getDistanceBetweenCirclesByOverlap: getDistanceBetweenCirclesByOverlap,
  919. layoutGreedyVenn: layoutGreedyVenn,
  920. loss: loss,
  921. nelderMead: nelderMeadMixin,
  922. processVennData: processVennData,
  923. sortByTotalOverlap: sortByTotalOverlap
  924. }
  925. };
  926. var vennPoint = {
  927. draw: draw,
  928. shouldDraw: function () {
  929. var point = this;
  930. // Only draw points with single sets.
  931. return !!point.shapeArgs;
  932. },
  933. isValid: function () {
  934. return isNumber(this.value);
  935. }
  936. };
  937. /**
  938. * A `venn` series. If the [type](#series.venn.type) option is
  939. * not specified, it is inherited from [chart.type](#chart.type).
  940. *
  941. * @extends series,plotOptions.venn
  942. * @excluding connectEnds, connectNulls, cropThreshold, dataParser, dataURL,
  943. * findNearestPointBy, getExtremesFromAll, label, linecap, lineWidth,
  944. * linkedTo, marker, negativeColor, pointInterval, pointIntervalUnit,
  945. * pointPlacement, pointStart, softThreshold, stack, stacking, steps,
  946. * threshold, xAxis, yAxis, zoneAxis, zones, dataSorting,
  947. * boostThreshold, boostBlending
  948. * @product highcharts
  949. * @requires modules/venn
  950. * @apioption series.venn
  951. */
  952. /**
  953. * @type {Array<*>}
  954. * @extends series.scatter.data
  955. * @excluding marker, x, y
  956. * @product highcharts
  957. * @apioption series.venn.data
  958. */
  959. /**
  960. * The name of the point. Used in data labels and tooltip. If name is not
  961. * defined then it will default to the joined values in
  962. * [sets](#series.venn.sets).
  963. *
  964. * @sample {highcharts} highcharts/demo/venn-diagram/
  965. * Venn diagram
  966. * @sample {highcharts} highcharts/demo/euler-diagram/
  967. * Euler diagram
  968. *
  969. * @type {number}
  970. * @since 7.0.0
  971. * @product highcharts
  972. * @apioption series.venn.data.name
  973. */
  974. /**
  975. * The value of the point, resulting in a relative area of the circle, or area
  976. * of overlap between two sets in the venn or euler diagram.
  977. *
  978. * @sample {highcharts} highcharts/demo/venn-diagram/
  979. * Venn diagram
  980. * @sample {highcharts} highcharts/demo/euler-diagram/
  981. * Euler diagram
  982. *
  983. * @type {number}
  984. * @since 7.0.0
  985. * @product highcharts
  986. * @apioption series.venn.data.value
  987. */
  988. /**
  989. * The set or sets the options will be applied to. If a single entry is defined,
  990. * then it will create a new set. If more than one entry is defined, then it
  991. * will define the overlap between the sets in the array.
  992. *
  993. * @sample {highcharts} highcharts/demo/venn-diagram/
  994. * Venn diagram
  995. * @sample {highcharts} highcharts/demo/euler-diagram/
  996. * Euler diagram
  997. *
  998. * @type {Array<string>}
  999. * @since 7.0.0
  1000. * @product highcharts
  1001. * @apioption series.venn.data.sets
  1002. */
  1003. /**
  1004. * @excluding halo
  1005. * @apioption series.venn.states.hover
  1006. */
  1007. /**
  1008. * @excluding halo
  1009. * @apioption series.venn.states.select
  1010. */
  1011. /**
  1012. * @private
  1013. * @class
  1014. * @name Highcharts.seriesTypes.venn
  1015. *
  1016. * @augments Highcharts.Series
  1017. */
  1018. seriesType('venn', 'scatter', vennOptions, vennSeries, vennPoint);
  1019. /* eslint-disable no-invalid-this */
  1020. // Modify final series options.
  1021. addEvent(seriesTypes.venn, 'afterSetOptions', function (e) {
  1022. var options = e.options, states = options.states;
  1023. if (this.is('venn')) {
  1024. // Explicitly disable all halo options.
  1025. Object.keys(states).forEach(function (state) {
  1026. states[state].halo = false;
  1027. });
  1028. }
  1029. });