/* * * (c) 2010-2019 Grzegorz Blachlinski, Sebastian Bochan * * License: www.highcharts.com/license */ 'use strict'; import H from '../parts/Globals.js'; import '../parts/Utilities.js'; import '../parts/Axis.js'; import '../parts/Color.js'; import '../parts/Point.js'; import '../parts/Series.js'; var seriesType = H.seriesType, defined = H.defined; /** * Packed bubble series * * @private * @class * @name Highcharts.seriesTypes.packedbubble * * @augments Highcharts.Series * * @requires modules:highcharts-more */ seriesType( 'packedbubble', 'bubble', /** * A packed bubble series is a two dimensional series type, where each point * renders a value in X, Y position. Each point is drawn as a bubble where * the bubbles don't overlap with each other and the radius of the bubble * related to the value. Requires `highcharts-more.js`. * * @sample {highcharts} highcharts/demo/packed-bubble/ * Packed-bubble chart * * @extends plotOptions.bubble * @since 7.0.0 * @product highcharts * @excluding connectEnds, connectNulls, jitter, keys, * sizeByAbsoluteValue, step, zMax, zMin * @optionparent plotOptions.packedbubble */ { /** * Minimum bubble size. Bubbles will automatically size between the * `minSize` and `maxSize` to reflect the value of each bubble. * Can be either pixels (when no unit is given), or a percentage of * the smallest one of the plot width and height. * * @sample {highcharts} highcharts/plotoptions/bubble-size/ * Bubble size * * @type {number|string} */ minSize: '10%', /** * Maximum bubble size. Bubbles will automatically size between the * `minSize` and `maxSize` to reflect the value of each bubble. * Can be either pixels (when no unit is given), or a percentage of * the smallest one of the plot width and height. * * @sample {highcharts} highcharts/plotoptions/bubble-size/ * Bubble size * * @type {number|string} */ maxSize: '100%', sizeBy: 'radius', zoneAxis: 'y', tooltip: { pointFormat: 'Value: {point.value}' } }, { pointArrayMap: ['value'], pointValKey: 'value', isCartesian: false, axisTypes: [], /** * Create a single array of all points from all series * @private * @param {Array} Array of all series objects * @return {Array} Returns the array of all points. */ accumulateAllPoints: function (series) { var chart = series.chart, allDataPoints = [], i, j; for (i = 0; i < chart.series.length; i++) { series = chart.series[i]; if (series.visible || !chart.options.chart.ignoreHiddenSeries) { // add data to array only if series is visible for (j = 0; j < series.yData.length; j++) { allDataPoints.push([ null, null, series.yData[j], series.index, j ]); } } } return allDataPoints; }, // Extend the base translate method to handle bubble size, and correct // positioning them. translate: function () { var positions, // calculated positions of bubbles in bubble array series = this, chart = series.chart, data = series.data, index = series.index, point, radius, i; this.processedXData = this.xData; this.generatePoints(); // merged data is an array with all of the data from all series if (!defined(chart.allDataPoints)) { chart.allDataPoints = series.accumulateAllPoints(series); // calculate radius for all added data series.getPointRadius(); } // after getting initial radius, calculate bubble positions positions = this.placeBubbles(chart.allDataPoints); // Set the shape type and arguments to be picked up in drawPoints for (i = 0; i < positions.length; i++) { if (positions[i][3] === index) { // update the series points with the values from positions // array point = data[positions[i][4]]; radius = positions[i][2]; point.plotX = positions[i][0] - chart.plotLeft + chart.diffX; point.plotY = positions[i][1] - chart.plotTop + chart.diffY; point.marker = H.extend(point.marker, { radius: radius, width: 2 * radius, height: 2 * radius }); } } }, /** * Check if two bubbles overlaps. * @private * @param {Array} bubble1 first bubble * @param {Array} bubble2 second bubble * @return {boolean} overlap or not */ checkOverlap: function (bubble1, bubble2) { var diffX = bubble1[0] - bubble2[0], // diff of X center values diffY = bubble1[1] - bubble2[1], // diff of Y center values sumRad = bubble1[2] + bubble2[2]; // sum of bubble radius return ( Math.sqrt(diffX * diffX + diffY * diffY) - Math.abs(sumRad) ) < -0.001; }, /** * Function that is adding one bubble based on positions and sizes * of two other bubbles, lastBubble is the last added bubble, * newOrigin is the bubble for positioning new bubbles. * nextBubble is the curently added bubble for which we are * calculating positions * @private * @param {Array} lastBubble The closest last bubble * @param {Array} newOrigin New bubble * @param {Array} nextBubble The closest next bubble * @return {Array} Bubble with correct positions */ positionBubble: function (lastBubble, newOrigin, nextBubble) { var sqrt = Math.sqrt, asin = Math.asin, acos = Math.acos, pow = Math.pow, abs = Math.abs, distance = sqrt( // dist between lastBubble and newOrigin pow((lastBubble[0] - newOrigin[0]), 2) + pow((lastBubble[1] - newOrigin[1]), 2) ), alfa = acos( // from cosinus theorem: alfa is an angle used for // calculating correct position ( pow(distance, 2) + pow(nextBubble[2] + newOrigin[2], 2) - pow(nextBubble[2] + lastBubble[2], 2) ) / (2 * (nextBubble[2] + newOrigin[2]) * distance) ), beta = asin( // from sinus theorem. abs(lastBubble[0] - newOrigin[0]) / distance ), // providing helping variables, related to angle between // lastBubble and newOrigin gamma = (lastBubble[1] - newOrigin[1]) < 0 ? 0 : Math.PI, // if new origin y is smaller than last bubble y value // (2 and 3 quarter), // add Math.PI to final angle delta = (lastBubble[0] - newOrigin[0]) * (lastBubble[1] - newOrigin[1]) < 0 ? 1 : -1, // (1st and 3rd quarter) finalAngle = gamma + alfa + beta * delta, cosA = Math.cos(finalAngle), sinA = Math.sin(finalAngle), posX = newOrigin[0] + (newOrigin[2] + nextBubble[2]) * sinA, // center of new origin + (radius1 + radius2) * sinus A posY = newOrigin[1] - (newOrigin[2] + nextBubble[2]) * cosA; return [ posX, posY, nextBubble[2], nextBubble[3], nextBubble[4] ]; // the same as described before }, /** * This is the main function responsible for positioning all of the * bubbles. * allDataPoints - bubble array, in format [pixel x value, * pixel y value, radius, related series index, related point index] * @private * @param {Array} allDataPoints All points from all series * @return {Array} Positions of all bubbles */ placeBubbles: function (allDataPoints) { var series = this, checkOverlap = series.checkOverlap, positionBubble = series.positionBubble, bubblePos = [], stage = 1, j = 0, k = 0, calculatedBubble, sortedArr, i; // sort all points sortedArr = allDataPoints.sort(function (a, b) { return b[2] - a[2]; }); // if length is 0, return empty array if (!sortedArr.length) { return []; } if (sortedArr.length < 2) { // if length is 1,return only one bubble return [ 0, 0, sortedArr[0][0], sortedArr[0][1], sortedArr[0][2] ]; } // create first bubble in the middle of the chart bubblePos.push([ [ 0, // starting in 0,0 coordinates 0, sortedArr[0][2], // radius sortedArr[0][3], // series index sortedArr[0][4] ] // point index ]); // 0 level bubble bubblePos.push([ [ 0, 0 - sortedArr[1][2] - sortedArr[0][2], // move bubble above first one sortedArr[1][2], sortedArr[1][3], sortedArr[1][4] ] ]); // 1 level 1st bubble // first two already positioned so starting from 2 for (i = 2; i < sortedArr.length; i++) { sortedArr[i][2] = sortedArr[i][2] || 1; // in case if radius is calculated as 0. calculatedBubble = positionBubble( bubblePos[stage][j], bubblePos[stage - 1][k], sortedArr[i] ); // calculate initial bubble position if (checkOverlap(calculatedBubble, bubblePos[stage][0])) { // if new bubble is overlapping with first bubble in // current level (stage) bubblePos.push([]); k = 0; // reset index of bubble, used for positioning the bubbles // around it, we are starting from first bubble in next // stage because we are changing level to higher bubblePos[stage + 1].push( positionBubble( bubblePos[stage][j], bubblePos[stage][0], sortedArr[i] ) ); // (last added bubble, 1st. bbl from cur stage, new bubble) stage++; // the new level is created, above current one j = 0; // set the index of bubble in current level to 0 } else if ( stage > 1 && bubblePos[stage - 1][k + 1] && checkOverlap(calculatedBubble, bubblePos[stage - 1][k + 1]) ) { // If new bubble is overlapping with one of the previous // stage bubbles, it means that - bubble, used for // positioning the bubbles around it has changed so we need // to recalculate it. k++; bubblePos[stage].push( positionBubble( bubblePos[stage][j], bubblePos[stage - 1][k], sortedArr[i] ) ); // (last added bubble, previous stage bubble, new bubble) j++; } else { // simply add calculated bubble j++; bubblePos[stage].push(calculatedBubble); } } series.chart.stages = bubblePos; // it may not be necessary but adding it just in case - // it is containing all of the bubble levels series.chart.rawPositions = [].concat.apply([], bubblePos); // bubble positions merged into one array series.resizeRadius(); return series.chart.rawPositions; }, /** * The function responsible for resizing the bubble radius. * In shortcut: it is taking the initially * calculated positions of bubbles. Then it is calculating the min max * of both dimensions, creating something in shape of bBox. * The comparison of bBox and the size of plotArea * (later it may be also the size set by customer) is giving the * value how to recalculate the radius so it will match the size * @private */ resizeRadius: function () { var chart = this.chart, positions = chart.rawPositions, min = Math.min, max = Math.max, plotLeft = chart.plotLeft, plotTop = chart.plotTop, chartHeight = chart.plotHeight, chartWidth = chart.plotWidth, minX, maxX, minY, maxY, radius, bBox, spaceRatio, smallerDimension, i; minX = minY = Number.POSITIVE_INFINITY; // set initial values maxX = maxY = Number.NEGATIVE_INFINITY; for (i = 0; i < positions.length; i++) { radius = positions[i][2]; minX = min(minX, positions[i][0] - radius); // (x center-radius) is the min x value used by specific bubble maxX = max(maxX, positions[i][0] + radius); minY = min(minY, positions[i][1] - radius); maxY = max(maxY, positions[i][1] + radius); } bBox = [maxX - minX, maxY - minY]; spaceRatio = [ (chartWidth - plotLeft) / bBox[0], (chartHeight - plotTop) / bBox[1] ]; smallerDimension = min.apply([], spaceRatio); if (Math.abs(smallerDimension - 1) > 1e-10) { // if bBox is considered not the same width as possible size for (i = 0; i < positions.length; i++) { positions[i][2] *= smallerDimension; } this.placeBubbles(positions); } else { // If no radius recalculation is needed, we need to position the // whole bubbles in center of chart plotarea for this, we are // adding two parameters, diffY and diffX, that are related to // differences between the initial center and the bounding box. chart.diffY = chartHeight / 2 + plotTop - minY - (maxY - minY) / 2; chart.diffX = chartWidth / 2 + plotLeft - minX - (maxX - minX) / 2; } }, // Calculate radius of bubbles in series. getPointRadius: function () { // bubbles array var series = this, chart = series.chart, plotWidth = chart.plotWidth, plotHeight = chart.plotHeight, seriesOptions = series.options, smallestSize = Math.min(plotWidth, plotHeight), extremes = {}, radii = [], allDataPoints = chart.allDataPoints, minSize, maxSize, value, radius; ['minSize', 'maxSize'].forEach(function (prop) { var length = parseInt(seriesOptions[prop], 10), isPercent = /%$/.test(length); extremes[prop] = isPercent ? smallestSize * length / 100 : length; }); chart.minRadius = minSize = extremes.minSize; chart.maxRadius = maxSize = extremes.maxSize; (allDataPoints || []).forEach(function (point, i) { value = point[2]; radius = series.getRadius( minSize, maxSize, minSize, maxSize, value ); if (value === 0) { radius = null; } allDataPoints[i][2] = radius; radii.push(radius); }); this.radii = radii; }, alignDataLabel: H.Series.prototype.alignDataLabel } ); // When one series is modified, the others need to be recomputed H.addEvent(H.seriesTypes.packedbubble, 'updatedData', function () { var self = this; this.chart.series.forEach(function (s) { if (s.type === self.type) { s.isDirty = true; } }); }); // Remove accumulated data points to redistribute all of them again // (i.e after hiding series by legend) H.addEvent(H.Chart, 'beforeRedraw', function () { if (this.allDataPoints) { delete this.allDataPoints; } }); /** * A `packedbubble` series. If the [type](#series.packedbubble.type) option is * not specified, it is inherited from [chart.type](#chart.type). * * @extends series,plotOptions.packedbubble * @excluding dataParser, dataURL, stack * @product highcharts * @apioption series.packedbubble */ /** * An array of data points for the series. For the `packedbubble` series type, * points can be given in the following ways: * * 1. An array of `value` values. * ```js * data: [5, 1, 20] * ``` * * 2. An array of objects with named values. The objects are point configuration * objects as seen below. If the total number of data points exceeds the * series' [turboThreshold](#series.packedbubble.turboThreshold), this option * is not available. * ```js * data: [{ * value: 1, * name: "Point2", * color: "#00FF00" * }, { * value: 5, * name: "Point1", * color: "#FF00FF" * }] * ``` * * @sample {highcharts} highcharts/series/data-array-of-objects/ * Config objects * * @type {Array} * @extends series.line.data * @excluding marker,x,y * @product highcharts * @apioption series.packedbubble.data */ /** * The value of a bubble. The bubble's size proportional to its `value`. * * @type {number} * @product highcharts * @apioption series.packedbubble.data.weight */ /** * @excluding enabled, enabledThreshold, height, radius, width * @apioption series.packedbubble.marker */