@amcharts/amcharts5
Version:
amCharts 5
425 lines • 19.1 kB
JavaScript
import { MapPointSeries } from "./MapPointSeries";
import { Component, DataItem } from "../../core/render/Component";
import { Container } from "../../core/render/Container";
import { Label } from "../../core/render/Label";
import * as $array from "../../core/util/Array";
import * as $object from "../../core/util/Object";
import * as d3hierarchy from "d3-hierarchy";
import * as $math from "../../core/util/Math";
/**
* A version of [[MapPointSeries]] which can automatically group closely located
* bullets into groups.
*
* @see {@link https://www.amcharts.com/docs/v5/charts/map-chart/clustered-point-series/} for more info
* @since 5.5.6
* @important
*/
export class ClusteredPointSeries extends MapPointSeries {
constructor() {
super(...arguments);
this._dataItem = this.makeDataItem({});
this._clusterIndex = 0;
this._clusters = [];
this.clusteredDataItems = [];
this._scatterIndex = 0;
this._scatters = [];
this._packLayout = d3hierarchy.pack();
this._spiral = [];
this._previousZL = 0;
}
_afterNew() {
this.fields.push("groupId");
this._setRawDefault("groupIdField", "groupId");
super._afterNew();
}
_afterDataChange() {
super._afterDataChange();
this._previousZL = 0;
}
_updateChildren() {
super._updateChildren();
if (this.isDirty("scatterRadius")) {
this._spiral = $math.spiralPoints(0, 0, 300, 300, 0, 3, 3, 0, 0);
}
const chart = this.chart;
if (chart) {
const zoomLevel = chart.get("zoomLevel", 1);
if (zoomLevel != this._previousZL) {
const clusterDelay = this.get("clusterDelay", 0);
if (clusterDelay) {
if (this._clusterDP) {
this._clusterDP.dispose();
this._clusterDP = this.setTimeout(() => {
this._doTheCluster();
}, clusterDelay);
}
else {
// first time without delay
this._doTheCluster();
this._clusterDP = this.setTimeout(() => { }, 0);
}
}
else {
this._doTheCluster();
}
this._previousZL = zoomLevel;
}
$array.each(this.clusteredDataItems, (dataItem) => {
const bullet = dataItem.get("bullet");
const longitude = dataItem.get("longitude", 0);
const latitude = dataItem.get("latitude", 0);
this._positionBulletReal(bullet, { type: "Point", coordinates: [longitude, latitude] }, [longitude, latitude]);
});
}
}
_doTheCluster() {
const groups = {};
// distribute to groups
$array.each(this.dataItems, (dataItem) => {
const groupId = dataItem.get("groupId", "_default");
if (!groups[groupId]) {
groups[groupId] = [];
}
groups[groupId].push(dataItem);
});
this._scatterIndex = -1;
this._scatters = [];
this._clusterIndex = -1;
this._clusters = [];
$array.each(this.clusteredDataItems, (dataItem) => {
dataItem.setRaw("children", undefined);
});
$array.each(this.dataItems, (dataItem) => {
dataItem.setRaw("cluster", undefined);
});
$object.each(groups, (_key, group) => {
this._scatterGroup(group);
});
$object.each(groups, (_key, group) => {
this._clusterGroup(group);
});
$array.each(this.dataItems, (dataItem) => {
if (!dataItem.get("cluster")) {
const bullets = dataItem.bullets;
if (bullets) {
$array.each(bullets, (bullet) => {
const sprite = bullet.get("sprite");
if (sprite) {
sprite.set("forceHidden", false);
}
});
}
}
});
}
/**
* Zooms to the area so that all clustered data items of a cluster would be
* visible.
*
* Pass in `true` as a second parameter to rotate that map so that the group
* is in the center. This is especially useful in the maps that use
* Orthographic (globe) projection.
*
* @param dataItem Group data item
* @param rotate Rotate the map so that group is in the center?
* @see {@link https://www.amcharts.com/docs/v5/charts/map-chart/clustered-point-series/#Drill_down} for more info
*/
zoomToCluster(dataItem, rotate) {
this.zoomToDataItems(dataItem.get("children", []), rotate);
}
_clusterGroup(dataItems) {
const chart = this.chart;
if (chart && chart.get("zoomLevel", 1) >= chart.get("maxZoomLevel", 100) * this.get("stopClusterZoom", 0.95)) {
// void
}
else {
const minDistance = this.get("minDistance", 20);
const minDistSq = minDistance * minDistance;
// Pre-filter to candidates with valid points
const candidates = [];
for (let i = 0; i < dataItems.length; i++) {
const di = dataItems[i];
if (!di.get("clipped")) {
const pt = di.get("point");
if (pt) {
candidates.push({ di: di, x: pt.x, y: pt.y });
}
}
}
// Spatial grid: cell size = minDistance so neighbors are always in adjacent cells
const grid = new Map();
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
const key = Math.floor(c.x / minDistance) + "," + Math.floor(c.y / minDistance);
let cell = grid.get(key);
if (!cell) {
cell = [];
grid.set(key, cell);
}
cell.push(i);
}
const visited = new Uint8Array(candidates.length);
for (let i = 0; i < candidates.length; i++) {
if (visited[i])
continue;
visited[i] = 1;
this._clusterIndex++;
this._clusters[this._clusterIndex] = [];
const cluster = this._clusters[this._clusterIndex];
const seed = candidates[i];
cluster.push(seed.di);
// BFS: only check 9 neighboring cells per expansion
const queue = [seed];
let head = 0;
while (head < queue.length) {
const pt = queue[head++];
const cx = Math.floor(pt.x / minDistance);
const cy = Math.floor(pt.y / minDistance);
for (let dcx = -1; dcx <= 1; dcx++) {
for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + "," + (cy + dcy));
if (cell) {
for (let k = 0; k < cell.length; k++) {
const idx = cell[k];
if (visited[idx])
continue;
const c = candidates[idx];
const dx = c.x - pt.x;
const dy = c.y - pt.y;
if (dx * dx + dy * dy < minDistSq) {
visited[idx] = 1;
cluster.push(c.di);
queue.push(c);
}
}
}
}
}
}
}
}
let i = 0;
const bulletMethod = this.get("clusteredBullet");
if (bulletMethod) {
$array.each(this._clusters, (cluster) => {
let sumX = 0;
let sumY = 0;
let len = cluster.length;
if (len > 1) {
let clusteredDataItem = this.clusteredDataItems[i];
if (!clusteredDataItem) {
clusteredDataItem = new DataItem(this, undefined, {});
const bullet = clusteredDataItem.set("bullet", bulletMethod(this._root, this, clusteredDataItem));
if (bullet) {
const sprite = bullet.get("sprite");
if (sprite) {
this.bulletsContainer.children.push(sprite);
sprite._setDataItem(clusteredDataItem);
this.root.events.once("frameended", () => {
if (sprite instanceof Container) {
sprite.walkChildren((child) => {
if (child instanceof Component) {
child.markDirtyValues();
}
});
}
});
}
}
this.clusteredDataItems.push(clusteredDataItem);
}
let groupId;
$array.each(cluster, (dataItem) => {
dataItem.setRaw("cluster", clusteredDataItem);
const point = dataItem.get("point");
if (point) {
sumX += point.x;
sumY += point.y;
}
const bullets = dataItem.bullets;
if (bullets) {
$array.each(bullets, (bullet) => {
const sprite = bullet.get("sprite");
if (sprite) {
sprite.set("forceHidden", true);
}
});
}
groupId = dataItem.get("groupId");
});
let averageX = sumX / len;
let averageY = sumY / len;
clusteredDataItem.setRaw("children", cluster);
clusteredDataItem.setRaw("groupId", groupId);
const prevLen = clusteredDataItem.get("value");
clusteredDataItem.setRaw("value", len);
const bullet = clusteredDataItem.get("bullet");
if (bullet) {
let geoPoint = this.chart.invert({ x: averageX, y: averageY });
if (geoPoint) {
clusteredDataItem.setAll({
longitude: geoPoint.longitude,
latitude: geoPoint.latitude,
});
}
this._positionBullets(clusteredDataItem);
const sprite = bullet.get("sprite");
if (sprite) {
sprite.set("forceHidden", false);
//sprite.setAll({ x: averageX, y: averageY });
if (prevLen != len) {
if (sprite instanceof Container) {
sprite.walkChildren((child) => {
if (child instanceof Label) {
child.text.markDirtyText();
}
});
}
}
}
}
i++;
}
});
}
$array.each(this.clusteredDataItems, (dataItem) => {
let children = dataItem.get("children");
if (!children || children.length == 0) {
const bullet = dataItem.get("bullet");
if (bullet) {
const sprite = bullet.get("sprite");
if (sprite) {
sprite.set("forceHidden", true);
}
}
}
});
}
_onDataClear() {
super._onDataClear();
$array.each(this.clusteredDataItems, (dataItem) => {
const bullet = dataItem.get("bullet");
if (bullet) {
const sprite = bullet.get("sprite");
if (sprite) {
sprite.dispose();
}
}
});
this.clusteredDataItems = [];
}
_scatterGroup(dataItems) {
const chart = this.chart;
if (chart && chart.get("zoomLevel", 1) >= chart.get("maxZoomLevel", 100) * this.get("stopClusterZoom", 0.95)) {
const scatterDistance = this.get("scatterDistance", 5);
const scatterDistSq = scatterDistance * scatterDistance;
// Pre-filter to candidates with valid points
const candidates = [];
for (let i = 0; i < dataItems.length; i++) {
const di = dataItems[i];
if (!di.get("clipped")) {
const pt = di.get("point");
if (pt) {
candidates.push({ di: di, x: pt.x, y: pt.y });
}
}
}
// Spatial grid: cell size = scatterDistance
const grid = new Map();
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
const key = Math.floor(c.x / scatterDistance) + "," + Math.floor(c.y / scatterDistance);
let cell = grid.get(key);
if (!cell) {
cell = [];
grid.set(key, cell);
}
cell.push(i);
}
const visited = new Uint8Array(candidates.length);
for (let i = 0; i < candidates.length; i++) {
if (visited[i])
continue;
visited[i] = 1;
this._scatterIndex++;
this._scatters[this._scatterIndex] = [];
const scatter = this._scatters[this._scatterIndex];
const seed = candidates[i];
scatter.push(seed.di);
const queue = [seed];
let head = 0;
while (head < queue.length) {
const pt = queue[head++];
const cx = Math.floor(pt.x / scatterDistance);
const cy = Math.floor(pt.y / scatterDistance);
for (let dcx = -1; dcx <= 1; dcx++) {
for (let dcy = -1; dcy <= 1; dcy++) {
const cell = grid.get((cx + dcx) + "," + (cy + dcy));
if (cell) {
for (let k = 0; k < cell.length; k++) {
const idx = cell[k];
if (visited[idx])
continue;
const c = candidates[idx];
const dx = c.x - pt.x;
const dy = c.y - pt.y;
if (dx * dx + dy * dy < scatterDistSq) {
visited[idx] = 1;
scatter.push(c.di);
queue.push(c);
}
}
}
}
}
}
}
$array.each(this._scatters, (scatter) => {
let len = scatter.length;
if (len > 1) {
let previousCircles = [];
let s = 0;
let radius = this.get("scatterRadius", 8);
$array.each(scatter, (dataItem) => {
let spiralPoint = this._spiral[s];
let intersects = true;
if (previousCircles.length > 0) {
while (intersects) {
$array.each(previousCircles, (previousCircle) => {
intersects = false;
while ($math.circlesOverlap({ x: spiralPoint.x, y: spiralPoint.y, radius: radius }, previousCircle)) {
s++;
if (this._spiral[s] == undefined) {
intersects = false;
}
else {
intersects = true;
spiralPoint = this._spiral[s];
}
}
});
}
}
const dx = spiralPoint.x;
const dy = spiralPoint.y;
previousCircles.push({ x: dx, y: dy, radius: radius });
dataItem.set("dx", dx);
dataItem.set("dy", dy);
const bullets = dataItem.bullets;
if (bullets) {
$array.each(bullets, (bullet) => {
const sprite = bullet.get("sprite");
if (sprite) {
sprite.setAll({ dx: dx, dy: dy });
}
});
}
});
}
});
}
}
}
ClusteredPointSeries.className = "ClusteredPointSeries";
ClusteredPointSeries.classNames = MapPointSeries.classNames.concat([ClusteredPointSeries.className]);
//# sourceMappingURL=ClusteredPointSeries.js.map