UNPKG

highcharts

Version:
1,346 lines (1,332 loc) 80.3 kB
/** * @license Highcharts JS v12.2.0 (2025-04-07) * @module highcharts/modules/marker-clusters * @requires highcharts * * Marker clusters module for Highcharts * * (c) 2010-2025 Wojciech Chmiel * * License: www.highcharts.com/license */ import * as __WEBPACK_EXTERNAL_MODULE__highcharts_src_js_8202131d__ from "../highcharts.src.js"; /******/ // The require scope /******/ var __webpack_require__ = {}; /******/ /************************************************************************/ /******/ /* webpack/runtime/compat get default export */ /******/ (() => { /******/ // getDefaultExport function for compatibility with non-harmony modules /******/ __webpack_require__.n = (module) => { /******/ var getter = module && module.__esModule ? /******/ () => (module['default']) : /******/ () => (module); /******/ __webpack_require__.d(getter, { a: getter }); /******/ return getter; /******/ }; /******/ })(); /******/ /******/ /* webpack/runtime/define property getters */ /******/ (() => { /******/ // define getter functions for harmony exports /******/ __webpack_require__.d = (exports, definition) => { /******/ for(var key in definition) { /******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }); /******/ } /******/ } /******/ }; /******/ })(); /******/ /******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ (() => { /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop)) /******/ })(); /******/ /************************************************************************/ ;// external ["../highcharts.src.js","default"] const external_highcharts_src_js_default_namespaceObject = __WEBPACK_EXTERNAL_MODULE__highcharts_src_js_8202131d__["default"]; var external_highcharts_src_js_default_default = /*#__PURE__*/__webpack_require__.n(external_highcharts_src_js_default_namespaceObject); ;// ./code/es-modules/Extensions/MarkerClusters/MarkerClusterDefaults.js /* * * * Marker clusters module. * * (c) 2010-2025 Torstein Honsi * * Author: Wojciech Chmiel * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ /* * * * API Options * * */ /** * Options for marker clusters, the concept of sampling the data * values into larger blocks in order to ease readability and * increase performance of the JavaScript charts. * * Note: marker clusters module is not working with `boost` * and `draggable-points` modules. * * The marker clusters feature requires the marker-clusters.js * file to be loaded, found in the modules directory of the download * package, or online at [code.highcharts.com/modules/marker-clusters.js * ](code.highcharts.com/modules/marker-clusters.js). * * @sample maps/marker-clusters/europe * Maps marker clusters * @sample highcharts/marker-clusters/basic * Scatter marker clusters * @sample maps/marker-clusters/optimized-kmeans * Marker clusters with colorAxis * * @product highcharts highmaps * @since 8.0.0 * @optionparent plotOptions.scatter.cluster * * @private */ const cluster = { /** * Whether to enable the marker-clusters module. * * @sample maps/marker-clusters/basic * Maps marker clusters * @sample highcharts/marker-clusters/basic * Scatter marker clusters */ enabled: false, /** * When set to `false` prevent cluster overlapping - this option * works only when `layoutAlgorithm.type = "grid"`. * * @sample highcharts/marker-clusters/grid * Prevent overlapping */ allowOverlap: true, /** * Options for the cluster marker animation. * @type {boolean|Partial<Highcharts.AnimationOptionsObject>} * @default { "duration": 500 } */ animation: { /** @ignore-option */ duration: 500 }, /** * Zoom the plot area to the cluster points range when a cluster is clicked. */ drillToCluster: true, /** * The minimum amount of points to be combined into a cluster. * This value has to be greater or equal to 2. * * @sample highcharts/marker-clusters/basic * At least three points in the cluster */ minimumClusterSize: 2, /** * Options for layout algorithm. Inside there * are options to change the type of the algorithm, gridSize, * distance or iterations. */ layoutAlgorithm: { /** * Type of the algorithm used to combine points into a cluster. * There are three available algorithms: * * 1) `grid` - grid-based clustering technique. Points are assigned * to squares of set size depending on their position on the plot * area. Points inside the grid square are combined into a cluster. * The grid size can be controlled by `gridSize` property * (grid size changes at certain zoom levels). * * 2) `kmeans` - based on K-Means clustering technique. In the * first step, points are divided using the grid method (distance * property is a grid size) to find the initial amount of clusters. * Next, each point is classified by computing the distance between * each cluster center and that point. When the closest cluster * distance is lower than distance property set by a user the point * is added to this cluster otherwise is classified as `noise`. The * algorithm is repeated until each cluster center not change its * previous position more than one pixel. This technique is more * accurate but also more time consuming than the `grid` algorithm, * especially for big datasets. * * 3) `optimizedKmeans` - based on K-Means clustering technique. This * algorithm uses k-means algorithm only on the chart initialization * or when chart extremes have greater range than on initialization. * When a chart is redrawn the algorithm checks only clustered points * distance from the cluster center and rebuild it when the point is * spaced enough to be outside the cluster. It provides performance * improvement and more stable clusters position yet can be used rather * on small and sparse datasets. * * By default, the algorithm depends on visible quantity of points * and `kmeansThreshold`. When there are more visible points than the * `kmeansThreshold` the `grid` algorithm is used, otherwise `kmeans`. * * The custom clustering algorithm can be added by assigning a callback * function as the type property. This function takes an array of * `processedXData`, `processedYData`, `processedXData` indexes and * `layoutAlgorithm` options as arguments and should return an object * with grouped data. * * The algorithm should return an object like that: * <pre>{ * clusterId1: [{ * x: 573, * y: 285, * index: 1 // point index in the data array * }, { * x: 521, * y: 197, * index: 2 * }], * clusterId2: [{ * ... * }] * ... * }</pre> * * `clusterId` (example above - unique id of a cluster or noise) * is an array of points belonging to a cluster. If the * array has only one point or fewer points than set in * `cluster.minimumClusterSize` it won't be combined into a cluster. * * @sample maps/marker-clusters/optimized-kmeans * Optimized K-Means algorithm * @sample highcharts/marker-clusters/kmeans * K-Means algorithm * @sample highcharts/marker-clusters/grid * Grid algorithm * @sample maps/marker-clusters/custom-alg * Custom algorithm * * @type {string|Function} * @see [cluster.minimumClusterSize](#plotOptions.scatter.cluster.minimumClusterSize) * @apioption plotOptions.scatter.cluster.layoutAlgorithm.type */ /** * When `type` is set to the `grid`, * `gridSize` is a size of a grid square element either as a number * defining pixels, or a percentage defining a percentage * of the plot area width. * * @type {number|string} */ gridSize: 50, /** * When `type` is set to `kmeans`, * `iterations` are the number of iterations that this algorithm will be * repeated to find clusters positions. * * @type {number} * @apioption plotOptions.scatter.cluster.layoutAlgorithm.iterations */ /** * When `type` is set to `kmeans`, * `distance` is a maximum distance between point and cluster center * so that this point will be inside the cluster. The distance * is either a number defining pixels or a percentage * defining a percentage of the plot area width. * * @type {number|string} */ distance: 40, /** * When `type` is set to `undefined` and there are more visible points * than the kmeansThreshold the `grid` algorithm is used to find * clusters, otherwise `kmeans`. It ensures good performance on * large datasets and better clusters arrangement after the zoom. */ kmeansThreshold: 100 }, /** * Options for the cluster marker. * @type {Highcharts.PointMarkerOptionsObject} * @extends plotOptions.series.marker * @excluding enabledThreshold, states */ marker: { /** @internal */ symbol: 'cluster', /** @internal */ radius: 15, /** @internal */ lineWidth: 0, /** @internal */ lineColor: "#ffffff" /* Palette.backgroundColor */ }, /** * Fires when the cluster point is clicked and `drillToCluster` is enabled. * One parameter, `event`, is passed to the function. The default action * is to zoom to the cluster points range. This can be prevented * by calling `event.preventDefault()`. * * @type {Highcharts.MarkerClusterDrillCallbackFunction} * @product highcharts highmaps * @see [cluster.drillToCluster](#plotOptions.scatter.cluster.drillToCluster) * @apioption plotOptions.scatter.cluster.events.drillToCluster */ /** * An array defining zones within marker clusters. * * In styled mode, the color zones are styled with the * `.highcharts-cluster-zone-{n}` class, or custom * classed from the `className` * option. * * @sample highcharts/marker-clusters/basic * Marker clusters zones * @sample maps/marker-clusters/custom-alg * Zones on maps * * @type {Array<*>} * @product highcharts highmaps * @apioption plotOptions.scatter.cluster.zones */ /** * Styled mode only. A custom class name for the zone. * * @sample highcharts/css/color-zones/ * Zones styled by class name * * @type {string} * @apioption plotOptions.scatter.cluster.zones.className */ /** * Settings for the cluster marker belonging to the zone. * * @see [cluster.marker](#plotOptions.scatter.cluster.marker) * @extends plotOptions.scatter.cluster.marker * @product highcharts highmaps * @apioption plotOptions.scatter.cluster.zones.marker */ /** * The value where the zone starts. * * @type {number} * @product highcharts highmaps * @apioption plotOptions.scatter.cluster.zones.from */ /** * The value where the zone ends. * * @type {number} * @product highcharts highmaps * @apioption plotOptions.scatter.cluster.zones.to */ /** * The fill color of the cluster marker in hover state. When * `undefined`, the series' or point's fillColor for normal * state is used. * * @type {Highcharts.ColorType} * @apioption plotOptions.scatter.cluster.states.hover.fillColor */ /** * Options for the cluster data labels. * @type {Highcharts.DataLabelsOptions} */ dataLabels: { /** @internal */ enabled: true, /** @internal */ format: '{point.clusterPointsAmount}', /** @internal */ verticalAlign: 'middle', /** @internal */ align: 'center', /** @internal */ style: { color: 'contrast' }, /** @internal */ inside: true } }; const tooltip = { /** * The HTML of the cluster point's in the tooltip. Works only with * marker-clusters module and analogously to * [pointFormat](#tooltip.pointFormat). * * The cluster tooltip can be also formatted using * `tooltip.formatter` callback function and `point.isCluster` flag. * * @sample highcharts/marker-clusters/grid * Format tooltip for cluster points. * * @sample maps/marker-clusters/europe/ * Format tooltip for clusters using tooltip.formatter * * @type {string} * @default Clustered points: {point.clusterPointsAmount} * @apioption tooltip.clusterFormat */ clusterFormat: '<span>Clustered points: ' + '{point.clusterPointsAmount}</span><br/>' }; /* * * * Default Export * * */ const MarkerClusterDefaults = { cluster, tooltip }; /* harmony default export */ const MarkerClusters_MarkerClusterDefaults = (MarkerClusterDefaults); ;// ./code/es-modules/Data/ColumnUtils.js /* * * * (c) 2020-2025 Highsoft AS * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * Authors: * - Dawid Dragula * * */ /** * Utility functions for columns that can be either arrays or typed arrays. * @private */ var ColumnUtils; (function (ColumnUtils) { /* * * * Declarations * * */ /* * * * Functions * * */ /** * Sets the length of the column array. * * @param {DataTable.Column} column * Column to be modified. * * @param {number} length * New length of the column. * * @param {boolean} asSubarray * If column is a typed array, return a subarray instead of a new array. It * is faster `O(1)`, but the entire buffer will be kept in memory until all * views to it are destroyed. Default is `false`. * * @return {DataTable.Column} * Modified column. * * @private */ function setLength(column, length, asSubarray) { if (Array.isArray(column)) { column.length = length; return column; } return column[asSubarray ? 'subarray' : 'slice'](0, length); } ColumnUtils.setLength = setLength; /** * Splices a column array. * * @param {DataTable.Column} column * Column to be modified. * * @param {number} start * Index at which to start changing the array. * * @param {number} deleteCount * An integer indicating the number of old array elements to remove. * * @param {boolean} removedAsSubarray * If column is a typed array, return a subarray instead of a new array. It * is faster `O(1)`, but the entire buffer will be kept in memory until all * views to it are destroyed. Default is `true`. * * @param {Array<number>|TypedArray} items * The elements to add to the array, beginning at the start index. If you * don't specify any elements, `splice()` will only remove elements from the * array. * * @return {SpliceResult} * Object containing removed elements and the modified column. * * @private */ function splice(column, start, deleteCount, removedAsSubarray, items = []) { if (Array.isArray(column)) { if (!Array.isArray(items)) { items = Array.from(items); } return { removed: column.splice(start, deleteCount, ...items), array: column }; } const Constructor = Object.getPrototypeOf(column) .constructor; const removed = column[removedAsSubarray ? 'subarray' : 'slice'](start, start + deleteCount); const newLength = column.length - deleteCount + items.length; const result = new Constructor(newLength); result.set(column.subarray(0, start), 0); result.set(items, start); result.set(column.subarray(start + deleteCount), start + items.length); return { removed: removed, array: result }; } ColumnUtils.splice = splice; })(ColumnUtils || (ColumnUtils = {})); /* * * * Default Export * * */ /* harmony default export */ const Data_ColumnUtils = (ColumnUtils); ;// ./code/es-modules/Data/DataTableCore.js /* * * * (c) 2009-2025 Highsoft AS * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * Authors: * - Sophie Bremer * - Gøran Slettemark * - Torstein Hønsi * * */ const { setLength, splice } = Data_ColumnUtils; const { fireEvent, objectEach, uniqueKey } = (external_highcharts_src_js_default_default()); /* * * * Class * * */ /** * Class to manage columns and rows in a table structure. It provides methods * to add, remove, and manipulate columns and rows, as well as to retrieve data * from specific cells. * * @class * @name Highcharts.DataTable * * @param {Highcharts.DataTableOptions} [options] * Options to initialize the new DataTable instance. */ class DataTableCore { /** * Constructs an instance of the DataTable class. * * @example * const dataTable = new Highcharts.DataTableCore({ * columns: { * year: [2020, 2021, 2022, 2023], * cost: [11, 13, 12, 14], * revenue: [12, 15, 14, 18] * } * }); * * @param {Highcharts.DataTableOptions} [options] * Options to initialize the new DataTable instance. */ constructor(options = {}) { /** * Whether the ID was automatic generated or given in the constructor. * * @name Highcharts.DataTable#autoId * @type {boolean} */ this.autoId = !options.id; this.columns = {}; /** * ID of the table for identification purposes. * * @name Highcharts.DataTable#id * @type {string} */ this.id = (options.id || uniqueKey()); this.modified = this; this.rowCount = 0; this.versionTag = uniqueKey(); let rowCount = 0; objectEach(options.columns || {}, (column, columnName) => { this.columns[columnName] = column.slice(); rowCount = Math.max(rowCount, column.length); }); this.applyRowCount(rowCount); } /* * * * Functions * * */ /** * Applies a row count to the table by setting the `rowCount` property and * adjusting the length of all columns. * * @private * @param {number} rowCount The new row count. */ applyRowCount(rowCount) { this.rowCount = rowCount; objectEach(this.columns, (column, columnName) => { if (column.length !== rowCount) { this.columns[columnName] = setLength(column, rowCount); } }); } /** * Delete rows. Simplified version of the full * `DataTable.deleteRows` method. * * @param {number} rowIndex * The start row index * * @param {number} [rowCount=1] * The number of rows to delete * * @return {void} * * @emits #afterDeleteRows */ deleteRows(rowIndex, rowCount = 1) { if (rowCount > 0 && rowIndex < this.rowCount) { let length = 0; objectEach(this.columns, (column, columnName) => { this.columns[columnName] = splice(column, rowIndex, rowCount).array; length = column.length; }); this.rowCount = length; } fireEvent(this, 'afterDeleteRows', { rowIndex, rowCount }); this.versionTag = uniqueKey(); } /** * Fetches the given column by the canonical column name. Simplified version * of the full `DataTable.getRow` method, always returning by reference. * * @param {string} columnName * Name of the column to get. * * @return {Highcharts.DataTableColumn|undefined} * A copy of the column, or `undefined` if not found. */ getColumn(columnName, // eslint-disable-next-line @typescript-eslint/no-unused-vars asReference) { return this.columns[columnName]; } /** * Retrieves all or the given columns. Simplified version of the full * `DataTable.getColumns` method, always returning by reference. * * @param {Array<string>} [columnNames] * Column names to retrieve. * * @return {Highcharts.DataTableColumnCollection} * Collection of columns. If a requested column was not found, it is * `undefined`. */ getColumns(columnNames, // eslint-disable-next-line @typescript-eslint/no-unused-vars asReference) { return (columnNames || Object.keys(this.columns)).reduce((columns, columnName) => { columns[columnName] = this.columns[columnName]; return columns; }, {}); } /** * Retrieves the row at a given index. * * @param {number} rowIndex * Row index to retrieve. First row has index 0. * * @param {Array<string>} [columnNames] * Column names to retrieve. * * @return {Record<string, number|string|undefined>|undefined} * Returns the row values, or `undefined` if not found. */ getRow(rowIndex, columnNames) { return (columnNames || Object.keys(this.columns)).map((key) => this.columns[key]?.[rowIndex]); } /** * Sets cell values for a column. Will insert a new column, if not found. * * @param {string} columnName * Column name to set. * * @param {Highcharts.DataTableColumn} [column] * Values to set in the column. * * @param {number} [rowIndex] * Index of the first row to change. (Default: 0) * * @param {Record<string, (boolean|number|string|null|undefined)>} [eventDetail] * Custom information for pending events. * * @emits #setColumns * @emits #afterSetColumns */ setColumn(columnName, column = [], rowIndex = 0, eventDetail) { this.setColumns({ [columnName]: column }, rowIndex, eventDetail); } /** * Sets cell values for multiple columns. Will insert new columns, if not * found. Simplified version of the full `DataTableCore.setColumns`, limited * to full replacement of the columns (undefined `rowIndex`). * * @param {Highcharts.DataTableColumnCollection} columns * Columns as a collection, where the keys are the column names. * * @param {number} [rowIndex] * Index of the first row to change. Ignored in the `DataTableCore`, as it * always replaces the full column. * * @param {Record<string, (boolean|number|string|null|undefined)>} [eventDetail] * Custom information for pending events. * * @emits #setColumns * @emits #afterSetColumns */ setColumns(columns, rowIndex, eventDetail) { let rowCount = this.rowCount; objectEach(columns, (column, columnName) => { this.columns[columnName] = column.slice(); rowCount = column.length; }); this.applyRowCount(rowCount); if (!eventDetail?.silent) { fireEvent(this, 'afterSetColumns'); this.versionTag = uniqueKey(); } } /** * Sets cell values of a row. Will insert a new row if no index was * provided, or if the index is higher than the total number of table rows. * A simplified version of the full `DateTable.setRow`, limited to objects. * * @param {Record<string, number|string|undefined>} row * Cell values to set. * * @param {number} [rowIndex] * Index of the row to set. Leave `undefined` to add as a new row. * * @param {boolean} [insert] * Whether to insert the row at the given index, or to overwrite the row. * * @param {Record<string, (boolean|number|string|null|undefined)>} [eventDetail] * Custom information for pending events. * * @emits #afterSetRows */ setRow(row, rowIndex = this.rowCount, insert, eventDetail) { const { columns } = this, indexRowCount = insert ? this.rowCount + 1 : rowIndex + 1; objectEach(row, (cellValue, columnName) => { let column = columns[columnName] || eventDetail?.addColumns !== false && new Array(indexRowCount); if (column) { if (insert) { column = splice(column, rowIndex, 0, true, [cellValue]).array; } else { column[rowIndex] = cellValue; } columns[columnName] = column; } }); if (indexRowCount > this.rowCount) { this.applyRowCount(indexRowCount); } if (!eventDetail?.silent) { fireEvent(this, 'afterSetRows'); this.versionTag = uniqueKey(); } } } /* * * * Default Export * * */ /* harmony default export */ const Data_DataTableCore = (DataTableCore); /* * * * API Declarations * * */ /** * A typed array. * @typedef {Int8Array|Uint8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float32Array|Float64Array} Highcharts.TypedArray * //** * A column of values in a data table. * @typedef {Array<boolean|null|number|string|undefined>|Highcharts.TypedArray} Highcharts.DataTableColumn */ /** * A collection of data table columns defined by a object where the key is the * column name and the value is an array of the column values. * @typedef {Record<string, Highcharts.DataTableColumn>} Highcharts.DataTableColumnCollection */ /** * Options for the `DataTable` or `DataTableCore` classes. * @interface Highcharts.DataTableOptions */ /** * The column options for the data table. The columns are defined by an object * where the key is the column ID and the value is an array of the column * values. * * @name Highcharts.DataTableOptions.columns * @type {Highcharts.DataTableColumnCollection|undefined} */ /** * Custom ID to identify the new DataTable instance. * * @name Highcharts.DataTableOptions.id * @type {string|undefined} */ (''); // Keeps doclets above in JS file ;// ./code/es-modules/Extensions/MarkerClusters/MarkerClusterScatter.js /* * * * Marker clusters module. * * (c) 2010-2025 Torstein Honsi * * Author: Wojciech Chmiel * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ const { animObject } = (external_highcharts_src_js_default_default()); const { cluster: clusterDefaults } = MarkerClusters_MarkerClusterDefaults; const { addEvent, defined, error, isArray, isFunction, isObject, isNumber, merge, objectEach: MarkerClusterScatter_objectEach, relativeLength, syncTimeout } = (external_highcharts_src_js_default_default()); /* * * * Constants * * */ const markerClusterAlgorithms = { grid: function (dataX, dataY, dataIndexes, options) { const series = this, grid = {}, gridOffset = series.getGridOffset(), scaledGridSize = series.getScaledGridSize(options); let x, y, gridX, gridY, key, i; for (i = 0; i < dataX.length; i++) { const p = valuesToPixels(series, { x: dataX[i], y: dataY[i] }); x = p.x - gridOffset.plotLeft; y = p.y - gridOffset.plotTop; gridX = Math.floor(x / scaledGridSize); gridY = Math.floor(y / scaledGridSize); key = gridY + ':' + gridX; grid[key] ?? (grid[key] = []); grid[key].push({ dataIndex: dataIndexes[i], x: dataX[i], y: dataY[i] }); } return grid; }, kmeans: function (dataX, dataY, dataIndexes, options) { const series = this, clusters = [], noise = [], group = {}, pointMaxDistance = options.processedDistance || clusterDefaults.layoutAlgorithm.distance, iterations = options.iterations, // Max pixel difference beetwen new and old cluster position. maxClusterShift = 1; let currentIteration = 0, repeat = true, pointX = 0, pointY = 0, tempPos, pointClusterDistance = []; options.processedGridSize = options.processedDistance; // Use grid method to get groupedData object. const groupedData = series.markerClusterAlgorithms ? series.markerClusterAlgorithms.grid.call(series, dataX, dataY, dataIndexes, options) : {}; // Find clusters amount and its start positions // based on grid grouped data. for (const key in groupedData) { if (groupedData[key].length > 1) { tempPos = getClusterPosition(groupedData[key]); clusters.push({ posX: tempPos.x, posY: tempPos.y, oldX: 0, oldY: 0, startPointsLen: groupedData[key].length, points: [] }); } } // Start kmeans iteration process. while (repeat) { for (const c of clusters) { c.points.length = 0; } noise.length = 0; for (let i = 0; i < dataX.length; i++) { pointX = dataX[i]; pointY = dataY[i]; pointClusterDistance = series.getClusterDistancesFromPoint(clusters, pointX, pointY); if (pointClusterDistance.length && pointClusterDistance[0].distance < pointMaxDistance) { clusters[pointClusterDistance[0].clusterIndex].points.push({ x: pointX, y: pointY, dataIndex: dataIndexes[i] }); } else { noise.push({ x: pointX, y: pointY, dataIndex: dataIndexes[i] }); } } // When cluster points array has only one point the // point should be classified again. for (let i = 0; i < clusters.length; i++) { if (clusters[i].points.length === 1) { pointClusterDistance = series.getClusterDistancesFromPoint(clusters, clusters[i].points[0].x, clusters[i].points[0].y); if (pointClusterDistance[1].distance < pointMaxDistance) { // Add point to the next closest cluster. clusters[pointClusterDistance[1].clusterIndex].points .push(clusters[i].points[0]); // Clear points array. clusters[pointClusterDistance[0].clusterIndex] .points.length = 0; } } } // Compute a new clusters position and check if it // is different than the old one. repeat = false; for (let i = 0; i < clusters.length; i++) { tempPos = getClusterPosition(clusters[i].points); clusters[i].oldX = clusters[i].posX; clusters[i].oldY = clusters[i].posY; clusters[i].posX = tempPos.x; clusters[i].posY = tempPos.y; // Repeat the algorithm if at least one cluster // is shifted more than maxClusterShift property. if (clusters[i].posX > clusters[i].oldX + maxClusterShift || clusters[i].posX < clusters[i].oldX - maxClusterShift || clusters[i].posY > clusters[i].oldY + maxClusterShift || clusters[i].posY < clusters[i].oldY - maxClusterShift) { repeat = true; } } // If iterations property is set repeat the algorithm // specified amount of times. if (iterations) { repeat = currentIteration < iterations - 1; } currentIteration++; } for (let i = 0, iEnd = clusters.length; i < iEnd; ++i) { group['cluster' + i] = clusters[i].points; } for (let i = 0, iEnd = noise.length; i < iEnd; ++i) { group['noise' + i] = [noise[i]]; } return group; }, optimizedKmeans: function (processedXData, processedYData, dataIndexes, options) { const series = this, pointMaxDistance = options.processedDistance || clusterDefaults.layoutAlgorithm.gridSize, extremes = series.getRealExtremes(), clusterMarkerOptions = (series.options.cluster || {}).marker; let distance, group = {}, offset, radius; if (!series.markerClusterInfo || (series.initMaxX && series.initMaxX < extremes.maxX || series.initMinX && series.initMinX > extremes.minX || series.initMaxY && series.initMaxY < extremes.maxY || series.initMinY && series.initMinY > extremes.minY)) { series.initMaxX = extremes.maxX; series.initMinX = extremes.minX; series.initMaxY = extremes.maxY; series.initMinY = extremes.minY; group = series.markerClusterAlgorithms ? series.markerClusterAlgorithms.kmeans.call(series, processedXData, processedYData, dataIndexes, options) : {}; series.baseClusters = null; } else { series.baseClusters ?? (series.baseClusters = { clusters: series.markerClusterInfo.clusters, noise: series.markerClusterInfo.noise }); for (const cluster of series.baseClusters.clusters) { cluster.pointsOutside = []; cluster.pointsInside = []; for (const dataPoint of cluster.data) { const dataPointPx = valuesToPixels(series, dataPoint), clusterPx = valuesToPixels(series, cluster); distance = Math.sqrt(Math.pow(dataPointPx.x - clusterPx.x, 2) + Math.pow(dataPointPx.y - clusterPx.y, 2)); if (cluster.clusterZone?.marker?.radius) { radius = cluster.clusterZone.marker.radius; } else if (clusterMarkerOptions?.radius) { radius = clusterMarkerOptions.radius; } else { radius = clusterDefaults.marker.radius; } offset = pointMaxDistance - radius >= 0 ? pointMaxDistance - radius : radius; if (distance > radius + offset && defined(cluster.pointsOutside)) { cluster.pointsOutside.push(dataPoint); } else if (defined(cluster.pointsInside)) { cluster.pointsInside.push(dataPoint); } } if (cluster.pointsInside.length) { group[cluster.id] = cluster.pointsInside; } let i = 0; for (const p of cluster.pointsOutside) { group[cluster.id + '_noise' + i++] = [p]; } } for (const noise of series.baseClusters.noise) { group[noise.id] = noise.data; } } return group; } }; /* * * * Variables * * */ let baseGeneratePoints, /** * Points that ids are included in the oldPointsStateId array are hidden * before animation. Other ones are destroyed. * @private */ oldPointsStateId = [], stateIdCounter = 0; /* * * * Functions * * */ /** @private */ function compose(highchartsDefaultOptions, ScatterSeriesClass) { const scatterProto = ScatterSeriesClass.prototype; if (!scatterProto.markerClusterAlgorithms) { baseGeneratePoints = scatterProto.generatePoints; scatterProto.markerClusterAlgorithms = markerClusterAlgorithms; scatterProto.animateClusterPoint = seriesAnimateClusterPoint; scatterProto.destroyClusteredData = seriesDestroyClusteredData; scatterProto.generatePoints = seriesGeneratePoints; scatterProto.getClusterDistancesFromPoint = seriesGetClusterDistancesFromPoint; scatterProto.getClusteredData = seriesGetClusteredData; scatterProto.getGridOffset = seriesGetGridOffset; scatterProto.getPointsState = seriesGetPointsState; scatterProto.getRealExtremes = seriesGetRealExtremes; scatterProto.getScaledGridSize = seriesGetScaledGridSize; scatterProto.hideClusteredData = seriesHideClusteredData; scatterProto.isValidGroupedDataObject = seriesIsValidGroupedDataObject; scatterProto.preventClusterCollisions = seriesPreventClusterCollisions; // Destroy grouped data on series destroy. addEvent(ScatterSeriesClass, 'destroy', scatterProto.destroyClusteredData); if (highchartsDefaultOptions.plotOptions) { highchartsDefaultOptions.plotOptions.series = merge(highchartsDefaultOptions.plotOptions.series, MarkerClusters_MarkerClusterDefaults); } } } /** * Util function. * @private */ function destroyOldPoints(oldState) { for (const key of Object.keys(oldState)) { oldState[key].point?.destroy?.(); } } /** * Util function. * @private */ function fadeInElement(elem, opacity, animation) { elem.attr({ opacity }).animate({ opacity: 1 }, animation); } /** * Util function. * @private */ function fadeInNewPointAndDestoryOld(newPointObj, oldPoints, animation, opacity) { // Fade in new point. fadeInStatePoint(newPointObj, opacity, animation, true, true); // Destroy old animated points. for (const p of oldPoints) { p.point?.destroy?.(); } } /** * Util function. * @private */ function fadeInStatePoint(stateObj, opacity, animation, fadeinGraphic, fadeinDataLabel) { if (stateObj.point) { if (fadeinGraphic && stateObj.point.graphic) { stateObj.point.graphic.show(); fadeInElement(stateObj.point.graphic, opacity, animation); } if (fadeinDataLabel && stateObj.point.dataLabel) { stateObj.point.dataLabel.show(); fadeInElement(stateObj.point.dataLabel, opacity, animation); } } } /** * Util function. * @private */ function getClusterPosition(points) { const pointsLen = points.length; let sumX = 0, sumY = 0; for (let i = 0; i < pointsLen; i++) { sumX += points[i].x; sumY += points[i].y; } return { x: sumX / pointsLen, y: sumY / pointsLen }; } /** * Util function.Prepare array with sorted data objects to be compared in * getPointsState method. * @private */ function getDataState(clusteredData, stateDataLen) { const state = []; state.length = stateDataLen; clusteredData.clusters.forEach(function (cluster) { cluster.data.forEach(function (elem) { state[elem.dataIndex] = elem; }); }); clusteredData.noise.forEach(function (noise) { state[noise.data[0].dataIndex] = noise.data[0]; }); return state; } /** * Util function. Generate unique stateId for a state element. * @private */ function getStateId() { return Math.random().toString(36).substring(2, 7) + '-' + stateIdCounter++; } /** * Util function. * @private */ function hideStatePoint(stateObj, hideGraphic, hideDataLabel) { if (stateObj.point) { if (hideGraphic && stateObj.point.graphic) { stateObj.point.graphic.hide(); } if (hideDataLabel && stateObj.point.dataLabel) { stateObj.point.dataLabel.hide(); } } } /** @private */ function onPointDrillToCluster(event) { const point = event.point || event.target; point.firePointEvent('drillToCluster', event, function (e) { const point = e.point || e.target, series = point.series, { xAxis, yAxis, chart } = series, { inverted, mapView, pointer } = chart, drillToCluster = series.options.cluster?.drillToCluster; if (drillToCluster && point.clusteredData) { const sortedDataX = point.clusteredData .map((data) => data.x) .sort((a, b) => a - b), sortedDataY = point.clusteredData .map((data) => data.y) .sort((a, b) => a - b), minX = sortedDataX[0], maxX = sortedDataX[sortedDataX.length - 1], minY = sortedDataY[0], maxY = sortedDataY[sortedDataY.length - 1], offsetX = Math.abs((maxX - minX) * 0.1), offsetY = Math.abs((maxY - minY) * 0.1), x1 = Math.min(minX, maxX) - offsetX, x2 = Math.max(minX, maxX) + offsetX, y1 = Math.min(minY, maxY) - offsetY, y2 = Math.max(minY, maxY) + offsetY; if (mapView) { mapView.fitToBounds({ x1, x2, y1, y2 }); } else if (xAxis && yAxis) { let x1Px = xAxis.toPixels(x1), x2Px = xAxis.toPixels(x2), y1Px = yAxis.toPixels(y1), y2Px = yAxis.toPixels(y2); if (inverted) { [x1Px, x2Px, y1Px, y2Px] = [y1Px, y2Px, x1Px, x2Px]; } if (x1Px > x2Px) { [x1Px, x2Px] = [x2Px, x1Px]; } if (y1Px > y2Px) { [y1Px, y2Px] = [y2Px, y1Px]; } if (pointer) { pointer.zoomX = true; pointer.zoomY = true; } chart.transform({ from: { x: x1Px, y: y1Px, width: x2Px - x1Px, height: y2Px - y1Px } }); } } }); } /** * Util function. * @private */ function pixelsToValues(series, pos) { const { chart, xAxis, yAxis } = series; if (chart.mapView) { return chart.mapView.pixelsToProjectedUnits(pos); } return { x: xAxis ? xAxis.toValue(pos.x) : 0, y: yAxis ? yAxis.toValue(pos.y) : 0 }; } /** @private */ function seriesAnimateClusterPoint(clusterObj) { const series = this, chart = series.chart, mapView = chart.mapView, animation = animObject(series.options.cluster?.animation), animDuration = animation.duration || 500, pointsState = series.markerClusterInfo?.pointsState, newState = pointsState?.newState, oldState = pointsState?.oldState, oldPoints = []; let parentId, oldPointObj, newPointObj, newPointBBox, offset = 0, newX = 0, newY = 0, isOldPointGrahic = false, isCbHandled = false; if (oldState && newState) { newPointObj = newState[clusterObj.stateId]; const newPos = valuesToPixels(series, newPointObj); newX = newPos.x - (mapView ? 0 : chart.plotLeft); newY = newPos.y - (mapView ? 0 : chart.plotTop); // Point has one ancestor. if (newPointObj.parentsId.length === 1) { parentId = newState?.[clusterObj.stateId].parentsId[0]; oldPointObj = oldState[parentId]; // If old and new positions are the same do not animate. if (newPointObj.point?.graphic && oldPointObj.point?.plotX && oldPointObj.point.plotY && (oldPointObj.point.plotX !== newPointObj.point.plotX || oldPointObj.point.plotY !== newPointObj.point.plotY)) { newPointBBox = newPointObj.point.graphic.getBBox(); // Marker image does not have the offset (#14342). offset = newPointObj.point.graphic?.isImg ? 0 : newPointBBox.width / 2; newPointObj.point.graphic.attr({ x: oldPointObj.point.plotX - offset, y: oldPointObj.point.plotY - offset }); newPointObj.point.graphic.animate({ x: newX - (newPointObj.point.graphic.radius || 0), y: newY - (newPointObj.point.graphic.radius || 0) }, animation, function () { isCbHandled = true; // Destroy old point. oldPointObj.point?.destroy?.(); }); // Data label animation. if (newPointObj.point.dataLabel?.alignAttr && oldPointObj.point.dataLabel?.alignAttr) { newPointObj.point.dataLabel.attr({ x: oldPointObj.point.dataLabel.alignAttr.x, y: oldPointObj.point.dataLabel.alignAttr.y }); newPointObj.point.dataLabel.animate({ x: newPointObj.point.dataLabel.alignAttr.x, y: newPointObj.point.dataLabel.alignAttr.y }, animation); } } } else if (newPointObj.parentsId.length === 0) { // Point has no ancestors - new point. // Hide new point. hideStatePoint(newPointObj, true, true); syncTimeout(function () { // Fade in new point. fadeInStatePoint(newPointObj, 0.1, animation, true, true); }, animDuration / 2); } else { // Point has many ancestors. // Hide new point before animation. hideStatePoint(newPointObj, true, true); newPointObj.parentsId.forEach(function (elem) { if (oldState?.[elem]) { oldPointObj = oldState[elem]; oldPoints.push(oldPointObj); if (oldPointObj.point?.graphic) { isOldPointGrahic = true; oldPointObj.point.graphic.show(); oldPointObj.point.graphic.animate({ x: newX - (oldPointObj.point.graphic.radius || 0), y: newY - (oldPointObj.point.graphic.radius || 0), opacity: 0.4 }, animation, function () { isCbHandled = true; fadeInNewPointAndDestoryOld(newPointObj, oldPoints, animation, 0.7); }); if (oldPointObj.point.dataLabel && oldPointObj.point.dataLabel.y !== -9999 && newPointObj.point?.dataLabel?.alignAttr) { oldPointObj.point.dataLabel.show(); oldPointObj.point.dataLabel.animate({ x: newPointObj.point.dataLabel.alignAttr.x, y: newPointObj.point.dataLabel.alignAttr.y, opacity: 0.4 }, animation); } } } }); // Make sure point is faded in. syncTimeout(function () { if (!isCbHandled) { fadeInNewPointAndDestoryOld(newPointObj, oldPoints, animation, 0.85); } }, animDuration); if (!isOldPointGrahic) { syncTimeout(function () { fadeInNewPointAndDestoryOld(newPointObj, oldPoints, animation, 0.1); }, animDuration / 2); } } } } /** * Destroy clustered data points. * @private */ function seriesDestroyClusteredData() { // Clear previous groups. this.markerClusterSeriesData?.forEach((point) => { point?.destroy?.(); }); this.markerClusterSeriesData = null; } /** * Override the generatePoints method by adding a reference to grouped data. * @private */ function seriesGeneratePoints() { const series = this, { chart } = series, mapView = chart.mapView, xData = series.getColumn('x'), yData = series.getColumn('y'), clusterOptions = series.options.cluster, realExtremes = series.getRealExtremes(), visibleXData = [], visibleYData = [], visibleDataIndexes = []; let oldPointsState, oldDataLen, oldMarkerClusterInfo, kmeansThreshold, cropDataOffsetX, cropDataOffsetY, seriesMinX, seriesMaxX, seriesMinY, seriesMaxY, type, algorithm, clusteredData, groupedData, layoutAlgOptions, point; // For map point series, we need to resolve lon, lat and geometry options // and project them on the plane in order to get x and y. In the regular // series flow, this is not done until the `translate` method because the // resulting [x, y] position depends on inset positions in the MapView. if (mapView && series.is('mappoint') && xData && yData) { ser