package.es-modules.Series.Venn.VennUtils.js Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of highcharts Show documentation
Show all versions of highcharts Show documentation
JavaScript charting framework
The newest version!
/* *
*
* Experimental Highcharts module which enables visualization of a Venn
* diagram.
*
* (c) 2016-2024 Highsoft AS
* Authors: Jon Arild Nygard
*
* Layout algorithm by Ben Frederickson:
* https://www.benfrederickson.com/better-venn-diagrams/
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
*
* */
'use strict';
import CU from '../../Core/Geometry/CircleUtilities.js';
const { getAreaOfCircle, getCircleCircleIntersection, getOverlapBetweenCircles: getOverlapBetweenCirclesByDistance, isPointInsideAllCircles, isPointInsideCircle, isPointOutsideAllCircles } = CU;
import GU from '../../Core/Geometry/GeometryUtilities.js';
const { getDistanceBetweenPoints } = GU;
import U from '../../Core/Utilities.js';
const { extend, isArray, isNumber, isObject, isString } = U;
/* *
*
* Functions
*
* */
/**
* Takes an array of relations and adds the properties `totalOverlap` and
* `overlapping` to each set. The property `totalOverlap` is the sum of
* value for each relation where this set is included. The property
* `overlapping` is a map of how much this set is overlapping another set.
* NOTE: This algorithm ignores relations consisting of more than 2 sets.
* @private
* @param {Array} relations
* The list of relations that should be sorted.
* @return {Array}
* Returns the modified input relations with added properties `totalOverlap`
* and `overlapping`.
*/
function addOverlapToSets(relations) {
// Calculate the amount of overlap per set.
const mapOfIdToProps = {};
relations
// Filter out relations consisting of 2 sets.
.filter((relation) => (relation.sets.length === 2))
// Sum up the amount of overlap for each set.
.forEach((relation) => {
relation.sets.forEach((set, i, arr) => {
if (!isObject(mapOfIdToProps[set])) {
mapOfIdToProps[set] = {
totalOverlap: 0,
overlapping: {}
};
}
mapOfIdToProps[set] = {
totalOverlap: (mapOfIdToProps[set].totalOverlap || 0) +
relation.value,
overlapping: {
...(mapOfIdToProps[set].overlapping || {}),
[arr[1 - i]]: relation.value
}
};
});
});
relations
// Filter out single sets
.filter(isSet)
// Extend the set with the calculated properties.
.forEach((set) => {
const properties = mapOfIdToProps[set.sets[0]];
extend(set, properties);
});
// Returns the modified relations.
return relations;
}
/**
* Finds the root of a given function. The root is the input value needed
* for a function to return 0.
*
* See https://en.wikipedia.org/wiki/Bisection_method#Algorithm
*
* TODO: Add unit tests.
*
* @param {Function} f
* The function to find the root of.
* @param {number} a
* The lowest number in the search range.
* @param {number} b
* The highest number in the search range.
* @param {number} [tolerance=1e-10]
* The allowed difference between the returned value and root.
* @param {number} [maxIterations=100]
* The maximum iterations allowed.
* @return {number}
* Root number.
*/
function bisect(f, a, b, tolerance, maxIterations) {
const fA = f(a), fB = f(b), nMax = maxIterations || 100, tol = tolerance || 1e-10;
let delta = b - a, x, fX, n = 1;
if (a >= b) {
throw new Error('a must be smaller than b.');
}
else if (fA * fB > 0) {
throw new Error('f(a) and f(b) must have opposite signs.');
}
if (fA === 0) {
x = a;
}
else if (fB === 0) {
x = b;
}
else {
while (n++ <= nMax && fX !== 0 && delta > tol) {
delta = (b - a) / 2;
x = a + delta;
fX = f(x);
// Update low and high for next search interval.
if (fA * fX > 0) {
a = x;
}
else {
b = x;
}
}
}
return x;
}
/**
* @private
*/
function getCentroid(simplex) {
const arr = simplex.slice(0, -1), length = arr.length, result = [], sum = (data, point) => {
data.sum += point[data.i];
return data;
};
for (let i = 0; i < length; i++) {
result[i] = arr.reduce(sum, { sum: 0, i: i }).sum / length;
}
return result;
}
/**
* Uses the bisection method to make a best guess of the ideal distance
* between two circles too get the desired overlap.
* Currently there is no known formula to calculate the distance from the
* area of overlap, which makes the bisection method preferred.
* @private
* @param {number} r1
* Radius of the first circle.
* @param {number} r2
* Radius of the second circle.
* @param {number} overlap
* The wanted overlap between the two circles.
* @return {number}
* Returns the distance needed to get the wanted overlap between the two
* circles.
*/
function getDistanceBetweenCirclesByOverlap(r1, r2, overlap) {
const maxDistance = r1 + r2;
let distance;
if (overlap <= 0) {
// If overlap is below or equal to zero, then there is no overlap.
distance = maxDistance;
}
else if (getAreaOfCircle(r1 < r2 ? r1 : r2) <= overlap) {
// When area of overlap is larger than the area of the smallest
// circle, then it is completely overlapping.
distance = 0;
}
else {
distance = bisect((x) => {
const actualOverlap = getOverlapBetweenCirclesByDistance(r1, r2, x);
// Return the difference between wanted and actual overlap.
return overlap - actualOverlap;
}, 0, maxDistance);
}
return distance;
}
/**
* Finds the available width for a label, by taking the label position and
* finding the largest distance, which is inside all internal circles, and
* outside all external circles.
*
* @private
* @param {Highcharts.PositionObject} pos
* The x and y coordinate of the label.
* @param {Array} internal
* Internal circles.
* @param {Array} external
* External circles.
* @return {number}
* Returns available width for the label.
*/
function getLabelWidth(pos, internal, external) {
const radius = internal.reduce((min, circle) => Math.min(circle.r, min), Infinity),
// Filter out external circles that are completely overlapping.
filteredExternals = external.filter((circle) => !isPointInsideCircle(pos, circle));
const findDistance = function (maxDistance, direction) {
return bisect((x) => {
const testPos = {
x: pos.x + (direction * x),
y: pos.y
}, isValid = (isPointInsideAllCircles(testPos, internal) &&
isPointOutsideAllCircles(testPos, filteredExternals));
// If the position is valid, then we want to move towards the
// max distance. If not, then we want to away from the max distance.
return -(maxDistance - x) + (isValid ? 0 : Number.MAX_VALUE);
}, 0, maxDistance);
};
// Find the smallest distance of left and right.
return Math.min(findDistance(radius, -1), findDistance(radius, 1)) * 2;
}
/**
* Calculates a margin for a point based on the internal and external
* circles. The margin describes if the point is well placed within the
* internal circles, and away from the external.
* @private
* @todo add unit tests.
* @param {Highcharts.PositionObject} point
* The point to evaluate.
* @param {Array} internal
* The internal circles.
* @param {Array} external
* The external circles.
* @return {number}
* Returns the margin.
*/
function getMarginFromCircles(point, internal, external) {
let margin = internal.reduce((margin, circle) => {
const m = circle.r - getDistanceBetweenPoints(point, circle);
return (m <= margin) ? m : margin;
}, Number.MAX_VALUE);
margin = external.reduce((margin, circle) => {
const m = getDistanceBetweenPoints(point, circle) - circle.r;
return (m <= margin) ? m : margin;
}, margin);
return margin;
}
/**
* Calculates the area of overlap between a list of circles.
* @private
* @todo add support for calculating overlap between more than 2 circles.
* @param {Array} circles
* List of circles with their given positions.
* @return {number}
* Returns the area of overlap between all the circles.
*/
function getOverlapBetweenCircles(circles) {
let overlap = 0;
// When there is only two circles we can find the overlap by using their
// radiuses and the distance between them.
if (circles.length === 2) {
const circle1 = circles[0];
const circle2 = circles[1];
overlap = getOverlapBetweenCirclesByDistance(circle1.r, circle2.r, getDistanceBetweenPoints(circle1, circle2));
}
return overlap;
}
// eslint-disable-next-line require-jsdoc
/**
*
*/
function isSet(x) {
return isArray(x.sets) && x.sets.length === 1;
}
// eslint-disable-next-line require-jsdoc
/**
*
*/
function isValidRelation(x) {
const map = {};
return (isObject(x) &&
(isNumber(x.value) && x.value > -1) &&
(isArray(x.sets) && x.sets.length > 0) &&
!x.sets.some(function (set) {
let invalid = false;
if (!map[set] && isString(set)) {
map[set] = true;
}
else {
invalid = true;
}
return invalid;
}));
}
// eslint-disable-next-line require-jsdoc
/**
*
*/
function isValidSet(x) {
return (isValidRelation(x) && isSet(x) && x.value > 0);
}
/**
* Uses a greedy approach to position all the sets. Works well with a small
* number of sets, and are in these cases a good choice aesthetically.
* @private
* @param {Array