You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

425 regels
15 KiB

  1. import KDBush from 'kdbush';
  2. const defaultOptions = {
  3. minZoom: 0, // min zoom to generate clusters on
  4. maxZoom: 16, // max zoom level to cluster the points on
  5. minPoints: 2, // minimum points to form a cluster
  6. radius: 40, // cluster radius in pixels
  7. extent: 512, // tile extent (radius is calculated relative to it)
  8. nodeSize: 64, // size of the KD-tree leaf node, affects performance
  9. log: false, // whether to log timing info
  10. // whether to generate numeric ids for input features (in vector tiles)
  11. generateId: false,
  12. // a reduce function for calculating custom cluster properties
  13. reduce: null, // (accumulated, props) => { accumulated.sum += props.sum; }
  14. // properties to use for individual points when running the reducer
  15. map: props => props // props => ({sum: props.my_value})
  16. };
  17. const fround = Math.fround || (tmp => ((x) => { tmp[0] = +x; return tmp[0]; }))(new Float32Array(1));
  18. const OFFSET_ZOOM = 2;
  19. const OFFSET_ID = 3;
  20. const OFFSET_PARENT = 4;
  21. const OFFSET_NUM = 5;
  22. const OFFSET_PROP = 6;
  23. export default class Supercluster {
  24. constructor(options) {
  25. this.options = Object.assign(Object.create(defaultOptions), options);
  26. this.trees = new Array(this.options.maxZoom + 1);
  27. this.stride = this.options.reduce ? 7 : 6;
  28. this.clusterProps = [];
  29. }
  30. load(points) {
  31. const {log, minZoom, maxZoom} = this.options;
  32. if (log) console.time('total time');
  33. const timerId = `prepare ${ points.length } points`;
  34. if (log) console.time(timerId);
  35. this.points = points;
  36. // generate a cluster object for each point and index input points into a KD-tree
  37. const data = [];
  38. for (let i = 0; i < points.length; i++) {
  39. const p = points[i];
  40. if (!p.geometry) continue;
  41. const [lng, lat] = p.geometry.coordinates;
  42. const x = fround(lngX(lng));
  43. const y = fround(latY(lat));
  44. // store internal point/cluster data in flat numeric arrays for performance
  45. data.push(
  46. x, y, // projected point coordinates
  47. Infinity, // the last zoom the point was processed at
  48. i, // index of the source feature in the original input array
  49. -1, // parent cluster id
  50. 1 // number of points in a cluster
  51. );
  52. if (this.options.reduce) data.push(0); // noop
  53. }
  54. let tree = this.trees[maxZoom + 1] = this._createTree(data);
  55. if (log) console.timeEnd(timerId);
  56. // cluster points on max zoom, then cluster the results on previous zoom, etc.;
  57. // results in a cluster hierarchy across zoom levels
  58. for (let z = maxZoom; z >= minZoom; z--) {
  59. const now = +Date.now();
  60. // create a new set of clusters for the zoom and index them with a KD-tree
  61. tree = this.trees[z] = this._createTree(this._cluster(tree, z));
  62. if (log) console.log('z%d: %d clusters in %dms', z, tree.numItems, +Date.now() - now);
  63. }
  64. if (log) console.timeEnd('total time');
  65. return this;
  66. }
  67. getClusters(bbox, zoom) {
  68. let minLng = ((bbox[0] + 180) % 360 + 360) % 360 - 180;
  69. const minLat = Math.max(-90, Math.min(90, bbox[1]));
  70. let maxLng = bbox[2] === 180 ? 180 : ((bbox[2] + 180) % 360 + 360) % 360 - 180;
  71. const maxLat = Math.max(-90, Math.min(90, bbox[3]));
  72. if (bbox[2] - bbox[0] >= 360) {
  73. minLng = -180;
  74. maxLng = 180;
  75. } else if (minLng > maxLng) {
  76. const easternHem = this.getClusters([minLng, minLat, 180, maxLat], zoom);
  77. const westernHem = this.getClusters([-180, minLat, maxLng, maxLat], zoom);
  78. return easternHem.concat(westernHem);
  79. }
  80. const tree = this.trees[this._limitZoom(zoom)];
  81. const ids = tree.range(lngX(minLng), latY(maxLat), lngX(maxLng), latY(minLat));
  82. const data = tree.data;
  83. const clusters = [];
  84. for (const id of ids) {
  85. const k = this.stride * id;
  86. clusters.push(data[k + OFFSET_NUM] > 1 ? getClusterJSON(data, k, this.clusterProps) : this.points[data[k + OFFSET_ID]]);
  87. }
  88. return clusters;
  89. }
  90. getChildren(clusterId) {
  91. const originId = this._getOriginId(clusterId);
  92. const originZoom = this._getOriginZoom(clusterId);
  93. const errorMsg = 'No cluster with the specified id.';
  94. const tree = this.trees[originZoom];
  95. if (!tree) throw new Error(errorMsg);
  96. const data = tree.data;
  97. if (originId * this.stride >= data.length) throw new Error(errorMsg);
  98. const r = this.options.radius / (this.options.extent * Math.pow(2, originZoom - 1));
  99. const x = data[originId * this.stride];
  100. const y = data[originId * this.stride + 1];
  101. const ids = tree.within(x, y, r);
  102. const children = [];
  103. for (const id of ids) {
  104. const k = id * this.stride;
  105. if (data[k + OFFSET_PARENT] === clusterId) {
  106. children.push(data[k + OFFSET_NUM] > 1 ? getClusterJSON(data, k, this.clusterProps) : this.points[data[k + OFFSET_ID]]);
  107. }
  108. }
  109. if (children.length === 0) throw new Error(errorMsg);
  110. return children;
  111. }
  112. getLeaves(clusterId, limit, offset) {
  113. limit = limit || 10;
  114. offset = offset || 0;
  115. const leaves = [];
  116. this._appendLeaves(leaves, clusterId, limit, offset, 0);
  117. return leaves;
  118. }
  119. getTile(z, x, y) {
  120. const tree = this.trees[this._limitZoom(z)];
  121. const z2 = Math.pow(2, z);
  122. const {extent, radius} = this.options;
  123. const p = radius / extent;
  124. const top = (y - p) / z2;
  125. const bottom = (y + 1 + p) / z2;
  126. const tile = {
  127. features: []
  128. };
  129. this._addTileFeatures(
  130. tree.range((x - p) / z2, top, (x + 1 + p) / z2, bottom),
  131. tree.data, x, y, z2, tile);
  132. if (x === 0) {
  133. this._addTileFeatures(
  134. tree.range(1 - p / z2, top, 1, bottom),
  135. tree.data, z2, y, z2, tile);
  136. }
  137. if (x === z2 - 1) {
  138. this._addTileFeatures(
  139. tree.range(0, top, p / z2, bottom),
  140. tree.data, -1, y, z2, tile);
  141. }
  142. return tile.features.length ? tile : null;
  143. }
  144. getClusterExpansionZoom(clusterId) {
  145. let expansionZoom = this._getOriginZoom(clusterId) - 1;
  146. while (expansionZoom <= this.options.maxZoom) {
  147. const children = this.getChildren(clusterId);
  148. expansionZoom++;
  149. if (children.length !== 1) break;
  150. clusterId = children[0].properties.cluster_id;
  151. }
  152. return expansionZoom;
  153. }
  154. _appendLeaves(result, clusterId, limit, offset, skipped) {
  155. const children = this.getChildren(clusterId);
  156. for (const child of children) {
  157. const props = child.properties;
  158. if (props && props.cluster) {
  159. if (skipped + props.point_count <= offset) {
  160. // skip the whole cluster
  161. skipped += props.point_count;
  162. } else {
  163. // enter the cluster
  164. skipped = this._appendLeaves(result, props.cluster_id, limit, offset, skipped);
  165. // exit the cluster
  166. }
  167. } else if (skipped < offset) {
  168. // skip a single point
  169. skipped++;
  170. } else {
  171. // add a single point
  172. result.push(child);
  173. }
  174. if (result.length === limit) break;
  175. }
  176. return skipped;
  177. }
  178. _createTree(data) {
  179. const tree = new KDBush(data.length / this.stride | 0, this.options.nodeSize, Float32Array);
  180. for (let i = 0; i < data.length; i += this.stride) tree.add(data[i], data[i + 1]);
  181. tree.finish();
  182. tree.data = data;
  183. return tree;
  184. }
  185. _addTileFeatures(ids, data, x, y, z2, tile) {
  186. for (const i of ids) {
  187. const k = i * this.stride;
  188. const isCluster = data[k + OFFSET_NUM] > 1;
  189. let tags, px, py;
  190. if (isCluster) {
  191. tags = getClusterProperties(data, k, this.clusterProps);
  192. px = data[k];
  193. py = data[k + 1];
  194. } else {
  195. const p = this.points[data[k + OFFSET_ID]];
  196. tags = p.properties;
  197. const [lng, lat] = p.geometry.coordinates;
  198. px = lngX(lng);
  199. py = latY(lat);
  200. }
  201. const f = {
  202. type: 1,
  203. geometry: [[
  204. Math.round(this.options.extent * (px * z2 - x)),
  205. Math.round(this.options.extent * (py * z2 - y))
  206. ]],
  207. tags
  208. };
  209. // assign id
  210. let id;
  211. if (isCluster || this.options.generateId) {
  212. // optionally generate id for points
  213. id = data[k + OFFSET_ID];
  214. } else {
  215. // keep id if already assigned
  216. id = this.points[data[k + OFFSET_ID]].id;
  217. }
  218. if (id !== undefined) f.id = id;
  219. tile.features.push(f);
  220. }
  221. }
  222. _limitZoom(z) {
  223. return Math.max(this.options.minZoom, Math.min(Math.floor(+z), this.options.maxZoom + 1));
  224. }
  225. _cluster(tree, zoom) {
  226. const {radius, extent, reduce, minPoints} = this.options;
  227. const r = radius / (extent * Math.pow(2, zoom));
  228. const data = tree.data;
  229. const nextData = [];
  230. const stride = this.stride;
  231. // loop through each point
  232. for (let i = 0; i < data.length; i += stride) {
  233. // if we've already visited the point at this zoom level, skip it
  234. if (data[i + OFFSET_ZOOM] <= zoom) continue;
  235. data[i + OFFSET_ZOOM] = zoom;
  236. // find all nearby points
  237. const x = data[i];
  238. const y = data[i + 1];
  239. const neighborIds = tree.within(data[i], data[i + 1], r);
  240. const numPointsOrigin = data[i + OFFSET_NUM];
  241. let numPoints = numPointsOrigin;
  242. // count the number of points in a potential cluster
  243. for (const neighborId of neighborIds) {
  244. const k = neighborId * stride;
  245. // filter out neighbors that are already processed
  246. if (data[k + OFFSET_ZOOM] > zoom) numPoints += data[k + OFFSET_NUM];
  247. }
  248. // if there were neighbors to merge, and there are enough points to form a cluster
  249. if (numPoints > numPointsOrigin && numPoints >= minPoints) {
  250. let wx = x * numPointsOrigin;
  251. let wy = y * numPointsOrigin;
  252. let clusterProperties;
  253. let clusterPropIndex = -1;
  254. // encode both zoom and point index on which the cluster originated -- offset by total length of features
  255. const id = ((i / stride | 0) << 5) + (zoom + 1) + this.points.length;
  256. for (const neighborId of neighborIds) {
  257. const k = neighborId * stride;
  258. if (data[k + OFFSET_ZOOM] <= zoom) continue;
  259. data[k + OFFSET_ZOOM] = zoom; // save the zoom (so it doesn't get processed twice)
  260. const numPoints2 = data[k + OFFSET_NUM];
  261. wx += data[k] * numPoints2; // accumulate coordinates for calculating weighted center
  262. wy += data[k + 1] * numPoints2;
  263. data[k + OFFSET_PARENT] = id;
  264. if (reduce) {
  265. if (!clusterProperties) {
  266. clusterProperties = this._map(data, i, true);
  267. clusterPropIndex = this.clusterProps.length;
  268. this.clusterProps.push(clusterProperties);
  269. }
  270. reduce(clusterProperties, this._map(data, k));
  271. }
  272. }
  273. data[i + OFFSET_PARENT] = id;
  274. nextData.push(wx / numPoints, wy / numPoints, Infinity, id, -1, numPoints);
  275. if (reduce) nextData.push(clusterPropIndex);
  276. } else { // left points as unclustered
  277. for (let j = 0; j < stride; j++) nextData.push(data[i + j]);
  278. if (numPoints > 1) {
  279. for (const neighborId of neighborIds) {
  280. const k = neighborId * stride;
  281. if (data[k + OFFSET_ZOOM] <= zoom) continue;
  282. data[k + OFFSET_ZOOM] = zoom;
  283. for (let j = 0; j < stride; j++) nextData.push(data[k + j]);
  284. }
  285. }
  286. }
  287. }
  288. return nextData;
  289. }
  290. // get index of the point from which the cluster originated
  291. _getOriginId(clusterId) {
  292. return (clusterId - this.points.length) >> 5;
  293. }
  294. // get zoom of the point from which the cluster originated
  295. _getOriginZoom(clusterId) {
  296. return (clusterId - this.points.length) % 32;
  297. }
  298. _map(data, i, clone) {
  299. if (data[i + OFFSET_NUM] > 1) {
  300. const props = this.clusterProps[data[i + OFFSET_PROP]];
  301. return clone ? Object.assign({}, props) : props;
  302. }
  303. const original = this.points[data[i + OFFSET_ID]].properties;
  304. const result = this.options.map(original);
  305. return clone && result === original ? Object.assign({}, result) : result;
  306. }
  307. }
  308. function getClusterJSON(data, i, clusterProps) {
  309. return {
  310. type: 'Feature',
  311. id: data[i + OFFSET_ID],
  312. properties: getClusterProperties(data, i, clusterProps),
  313. geometry: {
  314. type: 'Point',
  315. coordinates: [xLng(data[i]), yLat(data[i + 1])]
  316. }
  317. };
  318. }
  319. function getClusterProperties(data, i, clusterProps) {
  320. const count = data[i + OFFSET_NUM];
  321. const abbrev =
  322. count >= 10000 ? `${Math.round(count / 1000) }k` :
  323. count >= 1000 ? `${Math.round(count / 100) / 10 }k` : count;
  324. const propIndex = data[i + OFFSET_PROP];
  325. const properties = propIndex === -1 ? {} : Object.assign({}, clusterProps[propIndex]);
  326. return Object.assign(properties, {
  327. cluster: true,
  328. cluster_id: data[i + OFFSET_ID],
  329. point_count: count,
  330. point_count_abbreviated: abbrev
  331. });
  332. }
  333. // longitude/latitude to spherical mercator in [0..1] range
  334. function lngX(lng) {
  335. return lng / 360 + 0.5;
  336. }
  337. function latY(lat) {
  338. const sin = Math.sin(lat * Math.PI / 180);
  339. const y = (0.5 - 0.25 * Math.log((1 + sin) / (1 - sin)) / Math.PI);
  340. return y < 0 ? 0 : y > 1 ? 1 : y;
  341. }
  342. // spherical mercator to longitude/latitude
  343. function xLng(x) {
  344. return (x - 0.5) * 360;
  345. }
  346. function yLat(y) {
  347. const y2 = (180 - y * 360) * Math.PI / 180;
  348. return 360 * Math.atan(Math.exp(y2)) / Math.PI - 90;
  349. }