venn.src.js 39 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028
  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 '../parts/Color.js';
  19. var color = Color.parse;
  20. import H from '../parts/Globals.js';
  21. import U from '../parts/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 draw from '../mixins/draw-point.js';
  24. import geometry from '../mixins/geometry.js';
  25. import geometryCirclesModule from '../mixins/geometry-circles.js';
  26. 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;
  27. import nelderMeadModule from '../mixins/nelder-mead.js';
  28. // TODO: replace with individual imports
  29. var nelderMead = nelderMeadModule.nelderMead;
  30. import '../parts/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
  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: nelderMeadModule,
  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. * @product highcharts
  948. * @requires modules/venn
  949. * @apioption series.venn
  950. */
  951. /**
  952. * @type {Array<*>}
  953. * @extends series.scatter.data
  954. * @excluding marker, x, y
  955. * @product highcharts
  956. * @apioption series.venn.data
  957. */
  958. /**
  959. * The name of the point. Used in data labels and tooltip. If name is not
  960. * defined then it will default to the joined values in
  961. * [sets](#series.venn.sets).
  962. *
  963. * @sample {highcharts} highcharts/demo/venn-diagram/
  964. * Venn diagram
  965. * @sample {highcharts} highcharts/demo/euler-diagram/
  966. * Euler diagram
  967. *
  968. * @type {number}
  969. * @since 7.0.0
  970. * @product highcharts
  971. * @apioption series.venn.data.name
  972. */
  973. /**
  974. * The value of the point, resulting in a relative area of the circle, or area
  975. * of overlap between two sets in the venn or euler diagram.
  976. *
  977. * @sample {highcharts} highcharts/demo/venn-diagram/
  978. * Venn diagram
  979. * @sample {highcharts} highcharts/demo/euler-diagram/
  980. * Euler diagram
  981. *
  982. * @type {number}
  983. * @since 7.0.0
  984. * @product highcharts
  985. * @apioption series.venn.data.value
  986. */
  987. /**
  988. * The set or sets the options will be applied to. If a single entry is defined,
  989. * then it will create a new set. If more than one entry is defined, then it
  990. * will define the overlap between the sets in the array.
  991. *
  992. * @sample {highcharts} highcharts/demo/venn-diagram/
  993. * Venn diagram
  994. * @sample {highcharts} highcharts/demo/euler-diagram/
  995. * Euler diagram
  996. *
  997. * @type {Array<string>}
  998. * @since 7.0.0
  999. * @product highcharts
  1000. * @apioption series.venn.data.sets
  1001. */
  1002. /**
  1003. * @excluding halo
  1004. * @apioption series.venn.states.hover
  1005. */
  1006. /**
  1007. * @excluding halo
  1008. * @apioption series.venn.states.select
  1009. */
  1010. /**
  1011. * @private
  1012. * @class
  1013. * @name Highcharts.seriesTypes.venn
  1014. *
  1015. * @augments Highcharts.Series
  1016. */
  1017. seriesType('venn', 'scatter', vennOptions, vennSeries, vennPoint);
  1018. /* eslint-disable no-invalid-this */
  1019. // Modify final series options.
  1020. addEvent(seriesTypes.venn, 'afterSetOptions', function (e) {
  1021. var options = e.options, states = options.states;
  1022. if (this.is('venn')) {
  1023. // Explicitly disable all halo options.
  1024. Object.keys(states).forEach(function (state) {
  1025. states[state].halo = false;
  1026. });
  1027. }
  1028. });