├── .eslintrc.json ├── .github └── workflows │ └── npmpublish.yml ├── .gitignore ├── .npmignore ├── .travis.yml ├── README.md ├── index.js ├── kd.js ├── license.md ├── package.json ├── quad.js ├── research.js └── test.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "commonjs": true, 6 | "es6": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "rules": { 10 | "strict": 2, 11 | "indent": 0, 12 | "linebreak-style": 0, 13 | "quotes": 0, 14 | "semi": 0, 15 | "no-cond-assign": 1, 16 | "no-constant-condition": 1, 17 | "no-duplicate-case": 1, 18 | "no-empty": 1, 19 | "no-ex-assign": 1, 20 | "no-extra-boolean-cast": 1, 21 | "no-extra-semi": 1, 22 | "no-fallthrough": 1, 23 | "no-func-assign": 1, 24 | "no-global-assign": 1, 25 | "no-implicit-globals": 2, 26 | "no-inner-declarations": ["error", "functions"], 27 | "no-irregular-whitespace": 2, 28 | "no-loop-func": 1, 29 | "no-multi-str": 1, 30 | "no-mixed-spaces-and-tabs": 1, 31 | "no-proto": 1, 32 | "no-sequences": 1, 33 | "no-throw-literal": 1, 34 | "no-unmodified-loop-condition": 1, 35 | "no-useless-call": 1, 36 | "no-void": 1, 37 | "no-with": 2, 38 | "wrap-iife": 1, 39 | "no-redeclare": 1, 40 | "no-unused-vars": ["error", { "vars": "all", "args": "none" }], 41 | "no-sparse-arrays": 1 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/npmpublish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | - run: npm ci 16 | - run: npm test 17 | 18 | publish-npm: 19 | needs: build 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v1 23 | - uses: actions/setup-node@v1 24 | with: 25 | node-version: 12 26 | registry-url: https://registry.npmjs.org/ 27 | - run: npm ci 28 | - run: npm publish 29 | env: 30 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 31 | 32 | publish-gpr: 33 | needs: build 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v1 37 | - uses: actions/setup-node@v1 38 | with: 39 | node-version: 12 40 | registry-url: https://npm.pkg.github.com/ 41 | scope: '@your-github-username' 42 | - run: npm ci 43 | - run: npm publish 44 | env: 45 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .nyc_output 3 | coverage 4 | tmp 5 | *.log 6 | dist/ 7 | demo 8 | package-lock.json 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .nyc_output 2 | coverage 3 | tmp 4 | *.log 5 | test 6 | demo 7 | .travis.yml 8 | debug.js 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "node" 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # point-cluster [![Build Status](https://travis-ci.org/dy/point-cluster.svg?branch=master)](https://travis-ci.org/dy/point-cluster) [![experimental](https://img.shields.io/badge/stability-experimental-yellow.svg)](http://github.com/badges/stability-badges) 2 | 3 | Point clustering for 2D spatial indexing. Incorporates optimized quad-tree data structure. 4 | 5 | 18 | 19 | 20 | ```js 21 | const cluster = require('point-cluster') 22 | 23 | let ids = cluster(points) 24 | 25 | // get point ids in the indicated range 26 | let selectedIds = ids.range([10, 10, 20, 20]) 27 | 28 | // get levels of details: list of ids subranges for rendering purposes 29 | let lod = ids.range([10, 10, 20, 20], { lod: true }) 30 | ``` 31 | 32 | ## API 33 | 34 | ### `ids = cluster(points, options?)` 35 | 36 | Create index for the set of 2d `points` based on `options`. 37 | 38 | * `points` is an array of `[x,y, x,y, ...]` or `[[x,y], [x,y], ...]` coordinates. 39 | * `ids` is _Uint32Array_ with point ids sorted by zoom levels, suitable for WebGL buffer, subranging or alike. 40 | * `options` 41 | 42 | Option | Default | Description 43 | ---|---|--- 44 | `bounds` | `'auto'` | Data range, if different from `points` bounds, eg. in case of subdata. 45 | `depth` | `256` | Max number of levels. Points below the indicated level are grouped into single level. 46 | `output` | `'array'` | Output data array or data format. For available formats see [dtype](https://npmjs.org/package/dtype). 47 | 48 | 49 | 50 | 51 | --- 52 | 53 | 54 | ### `result = ids.range(box?, options?)` 55 | 56 | Get point ids from the indicated range. 57 | 58 | * `box` can be any rectangle object, eg. `[l, t, r, b]`, see [parse-rect](https://github.com/dy/parse-rect). 59 | * `options` 60 | 61 | Option | Default | Description 62 | ---|---|--- 63 | `lod` | `false` | Makes result a list of level details instead of ids, useful for obtaining subranges to render. 64 | `px` | `0` | Min pixel size in data dimension (number or `[width, height]` couple) to search for, to ignore lower levels. 65 | `level` | `null` | Max level to limit search. 66 | 67 | ```js 68 | let levels = ids.range([0,0, 100, 100], { lod: true, d: dataRange / canvas.width }) 69 | 70 | levels.forEach([from, to] => { 71 | // offset and count point to range in `ids` array 72 | render( ids.subarray( from, to ) ) 73 | }) 74 | ``` 75 | 76 | 77 | ### Related 78 | 79 | * [snap-points-2d](https://github.com/gl-vis/snap-points-2d) − grouping points by pixels. 80 | * [kdgrass](https://github.com/dy/kdgrass) − minimal kd-tree implementation. 81 | * [regl-scatter2d](https://github.com/dfreative/regl-scatter2d) − highly performant scatter2d plot. 82 | 83 | 84 | ## License 85 | 86 | © 2017 Dmitry Yv. MIT License 87 | 88 | Development supported by [plot.ly](https://github.com/plotly/). 89 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = require('./quad') 4 | -------------------------------------------------------------------------------- /kd.js: -------------------------------------------------------------------------------- 1 | /** 2 | * kd-tree based clustering 3 | * 4 | * Based on kdbush package 5 | */ 6 | 7 | 'use strict' 8 | 9 | 10 | module.exports = function clusterKD (points, ids, levels, weights, options) { 11 | let ptr = 0 12 | let n = ids.length 13 | let nodeSize = options.nodeSize 14 | 15 | sort(0, n - 1, 0) 16 | 17 | function sort(left, right, level) { 18 | let count = right - left 19 | weights[ptr] = count 20 | levels[ptr++] = level 21 | 22 | if (count <= nodeSize) return; 23 | 24 | let m = Math.floor((left + right) / 2); 25 | 26 | select(m, left, right, level % 2); 27 | 28 | sort(left, m - 1, level + 1); 29 | sort(m + 1, right, level + 1); 30 | } 31 | 32 | function select(k, left, right, inc) { 33 | while (right > left) { 34 | if (right - left > 600) { 35 | let n = right - left + 1; 36 | let m = k - left + 1; 37 | let z = Math.log(n); 38 | let s = 0.5 * Math.exp(2 * z / 3); 39 | let sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1); 40 | let newLeft = Math.max(left, Math.floor(k - m * s / n + sd)); 41 | let newRight = Math.min(right, Math.floor(k + (n - m) * s / n + sd)); 42 | select(k, newLeft, newRight, inc); 43 | } 44 | 45 | let t = points[2 * k + inc]; 46 | let i = left; 47 | let j = right; 48 | 49 | swapItem(left, k); 50 | if (points[2 * right + inc] > t) swapItem(left, right); 51 | 52 | while (i < j) { 53 | swapItem(i, j); 54 | i++; 55 | j--; 56 | while (points[2 * i + inc] < t) i++; 57 | while (points[2 * j + inc] > t) j--; 58 | } 59 | 60 | if (points[2 * left + inc] === t) swapItem(left, j); 61 | else { 62 | j++; 63 | swapItem(j, right); 64 | } 65 | 66 | if (j <= k) left = j + 1; 67 | if (k <= j) right = j - 1; 68 | } 69 | } 70 | 71 | function swapItem(i, j) { 72 | swap(ids, i, j); 73 | swap(points, 2 * i, 2 * j); 74 | swap(points, 2 * i + 1, 2 * j + 1); 75 | } 76 | 77 | function swap(arr, i, j) { 78 | let tmp = arr[i]; 79 | arr[i] = arr[j]; 80 | arr[j] = tmp; 81 | } 82 | } 83 | 84 | 85 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Dmitry Ivanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "point-cluster", 3 | "version": "3.1.8", 4 | "description": "Fast nd point clustering.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "node test", 8 | "build": "browserify demo.js -g bubleify | indexhtmlify | metadataify | github-cornerify > demo/index.html" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/dy/point-cluster.git" 13 | }, 14 | "keywords": [ 15 | "snap-points-2d", 16 | "supercluster", 17 | "quadtree", 18 | "quad-tree", 19 | "kdtree", 20 | "kd-tree", 21 | "ann-tree", 22 | "point", 23 | "scatter", 24 | "point2d", 25 | "2d", 26 | "cluster", 27 | "clustering", 28 | "geospatial", 29 | "markers", 30 | "round", 31 | "data", 32 | "vis", 33 | "gl-vis" 34 | ], 35 | "browserify": { 36 | "transform": [ 37 | "bubleify" 38 | ] 39 | }, 40 | "author": "Dmitry Yv ", 41 | "license": "MIT", 42 | "dependencies": { 43 | "array-bounds": "^1.0.1", 44 | "array-normalize": "^1.1.4", 45 | "binary-search-bounds": "^2.0.4", 46 | "bubleify": "^1.1.0", 47 | "clamp": "^1.0.1", 48 | "defined": "^1.0.0", 49 | "dtype": "^2.0.0", 50 | "flatten-vertex-data": "^1.0.2", 51 | "is-obj": "^1.0.1", 52 | "math-log2": "^1.0.1", 53 | "parse-rect": "^1.2.0", 54 | "pick-by-alias": "^1.2.0" 55 | }, 56 | "devDependencies": { 57 | "almost-equal": "^1.1.0", 58 | "canvas-fit": "^1.5.0", 59 | "gauss-random": "^1.0.1", 60 | "math-float64-bits": "^1.0.1", 61 | "math-float64-from-bits": "^1.0.0", 62 | "math-uint8-bits": "^1.0.0", 63 | "regl": "^1.3.1", 64 | "snap-points-2d": "^3.2.0", 65 | "tape": "^4.8.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /quad.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @module point-cluster/quad 3 | * 4 | * Bucket based quad tree clustering 5 | */ 6 | 7 | 'use strict' 8 | 9 | const search = require('binary-search-bounds') 10 | const clamp = require('clamp') 11 | const rect = require('parse-rect') 12 | const getBounds = require('array-bounds') 13 | const pick = require('pick-by-alias') 14 | const defined = require('defined') 15 | const flatten = require('flatten-vertex-data') 16 | const isObj = require('is-obj') 17 | const dtype = require('dtype') 18 | const log2 = require('math-log2') 19 | 20 | const MAX_GROUP_ID = 1073741824 21 | 22 | module.exports = function cluster (srcPoints, options) { 23 | if (!options) options = {} 24 | 25 | srcPoints = flatten(srcPoints, 'float64') 26 | 27 | options = pick(options, { 28 | bounds: 'range bounds dataBox databox', 29 | maxDepth: 'depth maxDepth maxdepth level maxLevel maxlevel levels', 30 | dtype: 'type dtype format out dst output destination' 31 | // sort: 'sortBy sortby sort', 32 | // pick: 'pick levelPoint', 33 | // nodeSize: 'node nodeSize minNodeSize minSize size' 34 | }) 35 | 36 | // let nodeSize = defined(options.nodeSize, 1) 37 | let maxDepth = defined(options.maxDepth, 255) 38 | let bounds = defined(options.bounds, getBounds(srcPoints, 2)) 39 | if (bounds[0] === bounds[2]) bounds[2]++ 40 | if (bounds[1] === bounds[3]) bounds[3]++ 41 | 42 | let points = normalize(srcPoints, bounds) 43 | 44 | // init variables 45 | let n = srcPoints.length >>> 1 46 | let ids 47 | if (!options.dtype) options.dtype = 'array' 48 | 49 | if (typeof options.dtype === 'string') { 50 | ids = new (dtype(options.dtype))(n) 51 | } 52 | else if (options.dtype) { 53 | ids = options.dtype 54 | if (Array.isArray(ids)) ids.length = n 55 | } 56 | for (let i = 0; i < n; ++i) { 57 | ids[i] = i 58 | } 59 | 60 | // representative point indexes for levels 61 | let levels = [] 62 | 63 | // starting indexes of subranges in sub levels, levels.length * 4 64 | let sublevels = [] 65 | 66 | // unique group ids, sorted in z-curve fashion within levels by shifting bits 67 | let groups = [] 68 | 69 | // level offsets in `ids` 70 | let offsets = [] 71 | 72 | 73 | // sort points 74 | sort(0, 0, 1, ids, 0, 1) 75 | 76 | 77 | // return reordered ids with provided methods 78 | // save level offsets in output buffer 79 | let offset = 0 80 | for (let level = 0; level < levels.length; level++) { 81 | let levelItems = levels[level] 82 | if (ids.set) ids.set(levelItems, offset) 83 | else { 84 | for (let i = 0, l = levelItems.length; i < l; i++) { 85 | ids[i + offset] = levelItems[i] 86 | } 87 | } 88 | let nextOffset = offset + levels[level].length 89 | offsets[level] = [offset, nextOffset] 90 | offset = nextOffset 91 | } 92 | 93 | ids.range = range 94 | 95 | return ids 96 | 97 | 98 | 99 | // FIXME: it is possible to create one typed array heap and reuse that to avoid memory blow 100 | function sort (x, y, diam, ids, level, group) { 101 | if (!ids.length) return null 102 | 103 | // save first point as level representative 104 | let levelItems = levels[level] || (levels[level] = []) 105 | let levelGroups = groups[level] || (groups[level] = []) 106 | let sublevel = sublevels[level] || (sublevels[level] = []) 107 | let offset = levelItems.length 108 | 109 | level++ 110 | 111 | // max depth reached - put all items into a first group 112 | // alternatively - if group id overflow - avoid proceeding 113 | if (level > maxDepth || group > MAX_GROUP_ID) { 114 | for (let i = 0; i < ids.length; i++) { 115 | levelItems.push(ids[i]) 116 | levelGroups.push(group) 117 | sublevel.push(null, null, null, null) 118 | } 119 | 120 | return offset 121 | } 122 | 123 | levelItems.push(ids[0]) 124 | levelGroups.push(group) 125 | 126 | if (ids.length <= 1) { 127 | sublevel.push(null, null, null, null) 128 | return offset 129 | } 130 | 131 | 132 | let d2 = diam * .5 133 | let cx = x + d2, cy = y + d2 134 | 135 | // distribute points by 4 buckets 136 | let lolo = [], lohi = [], hilo = [], hihi = [] 137 | 138 | for (let i = 1, l = ids.length; i < l; i++) { 139 | let idx = ids[i], 140 | x = points[idx * 2], 141 | y = points[idx * 2 + 1] 142 | x < cx ? (y < cy ? lolo.push(idx) : lohi.push(idx)) : (y < cy ? hilo.push(idx) : hihi.push(idx)) 143 | } 144 | 145 | group <<= 2 146 | 147 | sublevel.push( 148 | sort(x, y, d2, lolo, level, group), 149 | sort(x, cy, d2, lohi, level, group + 1), 150 | sort(cx, y, d2, hilo, level, group + 2), 151 | sort(cx, cy, d2, hihi, level, group + 3) 152 | ) 153 | 154 | return offset 155 | } 156 | 157 | // get all points within the passed range 158 | function range ( ...args ) { 159 | let options 160 | 161 | if (isObj(args[args.length - 1])) { 162 | let arg = args.pop() 163 | 164 | // detect if that was a rect object 165 | if (!args.length && (arg.x != null || arg.l != null || arg.left != null)) { 166 | args = [arg] 167 | options = {} 168 | } 169 | 170 | options = pick(arg, { 171 | level: 'level maxLevel', 172 | d: 'd diam diameter r radius px pxSize pixel pixelSize maxD size minSize', 173 | lod: 'lod details ranges offsets' 174 | }) 175 | } 176 | else { 177 | options = {} 178 | } 179 | 180 | if (!args.length) args = bounds 181 | 182 | let box = rect( ...args ) 183 | 184 | let [minX, minY, maxX, maxY] = [ 185 | Math.min(box.x, box.x + box.width), 186 | Math.min(box.y, box.y + box.height), 187 | Math.max(box.x, box.x + box.width), 188 | Math.max(box.y, box.y + box.height) 189 | ] 190 | 191 | let [nminX, nminY, nmaxX, nmaxY] = normalize([minX, minY, maxX, maxY], bounds ) 192 | 193 | let maxLevel = defined(options.level, levels.length) 194 | 195 | // limit maxLevel by px size 196 | if (options.d != null) { 197 | let d 198 | if (typeof options.d === 'number') d = [options.d, options.d] 199 | else if (options.d.length) d = options.d 200 | 201 | maxLevel = Math.min( 202 | Math.max( 203 | Math.ceil(-log2(Math.abs(d[0]) / (bounds[2] - bounds[0]))), 204 | Math.ceil(-log2(Math.abs(d[1]) / (bounds[3] - bounds[1]))) 205 | ), 206 | maxLevel 207 | ) 208 | } 209 | maxLevel = Math.min(maxLevel, levels.length) 210 | 211 | // return levels of details 212 | if (options.lod) { 213 | return lod(nminX, nminY, nmaxX, nmaxY, maxLevel) 214 | } 215 | 216 | 217 | 218 | // do selection ids 219 | let selection = [] 220 | 221 | // FIXME: probably we can do LOD here beforehead 222 | select( 0, 0, 1, 0, 0, 1) 223 | 224 | function select ( lox, loy, d, level, from, to ) { 225 | if (from === null || to === null) return 226 | 227 | let hix = lox + d 228 | let hiy = loy + d 229 | 230 | // if box does not intersect level - ignore 231 | if ( nminX > hix || nminY > hiy || nmaxX < lox || nmaxY < loy ) return 232 | if ( level >= maxLevel ) return 233 | if ( from === to ) return 234 | 235 | // if points fall into box range - take it 236 | let levelItems = levels[level] 237 | 238 | if (to === undefined) to = levelItems.length 239 | 240 | for (let i = from; i < to; i++) { 241 | let id = levelItems[i] 242 | 243 | let px = srcPoints[ id * 2 ] 244 | let py = srcPoints[ id * 2 + 1 ] 245 | 246 | if ( px >= minX && px <= maxX && py >= minY && py <= maxY ) {selection.push(id) 247 | } 248 | } 249 | 250 | // for every subsection do select 251 | let offsets = sublevels[ level ] 252 | let off0 = offsets[ from * 4 + 0 ] 253 | let off1 = offsets[ from * 4 + 1 ] 254 | let off2 = offsets[ from * 4 + 2 ] 255 | let off3 = offsets[ from * 4 + 3 ] 256 | let end = nextOffset(offsets, from + 1) 257 | 258 | let d2 = d * .5 259 | let nextLevel = level + 1 260 | select( lox, loy, d2, nextLevel, off0, off1 || off2 || off3 || end) 261 | select( lox, loy + d2, d2, nextLevel, off1, off2 || off3 || end) 262 | select( lox + d2, loy, d2, nextLevel, off2, off3 || end) 263 | select( lox + d2, loy + d2, d2, nextLevel, off3, end) 264 | } 265 | 266 | function nextOffset(offsets, from) { 267 | let offset = null, i = 0 268 | while(offset === null) { 269 | offset = offsets[ from * 4 + i ] 270 | i++ 271 | if (i > offsets.length) return null 272 | } 273 | return offset 274 | } 275 | 276 | return selection 277 | } 278 | 279 | // get range offsets within levels to render lods appropriate for zoom level 280 | // TODO: it is possible to store minSize of a point to optimize neede level calc 281 | function lod (lox, loy, hix, hiy, maxLevel) { 282 | let ranges = [] 283 | 284 | for (let level = 0; level < maxLevel; level++) { 285 | let levelGroups = groups[level] 286 | let from = offsets[level][0] 287 | 288 | let levelGroupStart = group(lox, loy, level) 289 | let levelGroupEnd = group(hix, hiy, level) 290 | 291 | // FIXME: utilize sublevels to speed up search range here 292 | let startOffset = search.ge(levelGroups, levelGroupStart) 293 | let endOffset = search.gt(levelGroups, levelGroupEnd, startOffset, levelGroups.length - 1) 294 | 295 | ranges[level] = [startOffset + from, endOffset + from] 296 | } 297 | 298 | return ranges 299 | } 300 | 301 | // get group id closest to the x,y coordinate, corresponding to a level 302 | function group (x, y, level) { 303 | let group = 1 304 | 305 | let cx = .5, cy = .5 306 | let diam = .5 307 | 308 | for (let i = 0; i < level; i++) { 309 | group <<= 2 310 | 311 | group += x < cx ? (y < cy ? 0 : 1) : (y < cy ? 2 : 3) 312 | 313 | diam *= .5 314 | 315 | cx += x < cx ? -diam : diam 316 | cy += y < cy ? -diam : diam 317 | } 318 | 319 | return group 320 | } 321 | } 322 | 323 | 324 | // normalize points by bounds 325 | function normalize (pts, bounds) { 326 | let [lox, loy, hix, hiy] = bounds 327 | let scaleX = 1.0 / (hix - lox) 328 | let scaleY = 1.0 / (hiy - loy) 329 | let result = new Array(pts.length) 330 | 331 | for (let i = 0, n = pts.length / 2; i < n; i++) { 332 | result[2*i] = clamp((pts[2*i] - lox) * scaleX, 0, 1) 333 | result[2*i+1] = clamp((pts[2*i+1] - loy) * scaleY, 0, 1) 334 | } 335 | 336 | return result 337 | } 338 | -------------------------------------------------------------------------------- /research.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const t = require('tape'); 5 | const cluster = require('./'); 6 | const random = require('gauss-random') 7 | const bitsf64 = require('math-float64-from-bits') 8 | const ui8bits = require('math-uint8-bits') 9 | const f64bits = require('math-float64-bits') 10 | 11 | 12 | t('packing 1e6 array', t => { 13 | let y = new Float64Array(1e6) 14 | let x = new Float64Array(1e6) 15 | console.time(1) 16 | y.subarray(0e5, 1e5) 17 | y.subarray(1e5, 2e5) 18 | y.subarray(2e5, 3e5) 19 | y.subarray(3e5, 4e5) 20 | y.subarray(4e5, 5e5) 21 | y.subarray(5e5, 6e5) 22 | y.subarray(6e5, 7e5) 23 | y.subarray(7e5, 8e5) 24 | y.subarray(8e5, 9e5) 25 | console.timeEnd(1) 26 | 27 | t.end() 28 | }) 29 | 30 | t('float64 packing', t => { 31 | // uint8 (level) + uint32 (id) + float32→normalized uint16 (x) 32 | 33 | let f64 = new Float64Array(1) 34 | let view = new DataView(f64.buffer) 35 | let ui8 = new Uint8Array(f64.buffer) 36 | let ui16 = new Uint16Array(f64.buffer) 37 | let ui32 = new Uint32Array(f64.buffer) 38 | 39 | // view.setUint8(7, parseInt('10000000', 2)) 40 | // view.setUint8(6, parseInt('00000000', 2)) 41 | // ui8[7] = 0xff 42 | // ui16[2] = 0x1000 43 | ui32[1] = 0x00ff0000 & 0x01 << 16 | 0x0000ffff & 0xffff 44 | console.log(f64[0], f64bits(f64[0])) 45 | 46 | let sign = '0' // ignore sign 47 | let exp = '00000000000' // write levels as exponent 48 | let fract = '0000000000000000000000000000000000000000000000000000' 49 | 50 | console.log(bitsf64(sign + exp + fract)) 51 | 52 | // 4 '0100000000010000000000000000000000000000000000000000000000000000' 53 | // -0 '1000000000000000000000000000000000000000000000000000000000000000' 54 | // NaN '0111111111111000000000000000000000000000000000000000000000000000' 55 | // +Inf '0111111111110000000000000000000000000000000000000000000000000000' 56 | // -Inf '1111111111110000000000000000000000000000000000000000000000000000' 57 | }) 58 | 59 | t('sorting comparison', t => { 60 | let N = 1e6 61 | 62 | let f64 = data(new Float64Array(N)) 63 | console.time(1) 64 | f64.sort() 65 | console.timeEnd(1) 66 | 67 | 68 | let f32 = data(new Float32Array(N)) 69 | console.time(2) 70 | f32.sort() 71 | console.timeEnd(2) 72 | 73 | 74 | let i32 = data(new Uint32Array(N), i => Math.random() * 0xffffffff) 75 | console.time(3) 76 | i32.sort() 77 | console.timeEnd(3) 78 | 79 | 80 | let i32b = data(new Uint32Array(N), i => Math.random() * 0xffffffff) 81 | console.time(4) 82 | i32b.sort((a, b) => a - b) 83 | console.timeEnd(4) 84 | 85 | 86 | let points = data(new Float64Array(N)) 87 | let levels = new Uint32Array(N) 88 | let weights = new Uint32Array(N) 89 | let ids = new Uint32Array(N) 90 | 91 | let level = 0, i = 0 92 | while (i < N) { 93 | let amt = Math.floor(Math.random() * (N / 16)) 94 | let end = Math.min(i + amt, N) 95 | for (; i < end; i++) { 96 | levels[i] = level 97 | ids[i] = i 98 | } 99 | level++ 100 | } 101 | 102 | const snapSort = require('snap-points-2d/lib/sort') 103 | 104 | console.time('snapsort') 105 | snapSort(levels, points, ids, weights, N) 106 | console.timeEnd('snapsort') 107 | }) 108 | 109 | 110 | function data(N=1e6, f) { 111 | let points 112 | if (N.length) { 113 | points = N 114 | } 115 | else { 116 | points = Array(N) 117 | } 118 | 119 | for (let i = 0; i < points.length; i++) { 120 | points[i] = f ? f(i) : random() 121 | } 122 | 123 | return points 124 | } 125 | 126 | 127 | 128 | /* 129 | // use x-sort if required 130 | if (options.sort) { 131 | // pack levels: uint8, x-coord: uint16 and id: uint32 to float64 132 | let packed = new Float64Array(n) 133 | let packedInt = new Uint32Array(packed.buffer) 134 | for (let i = 0; i < n; i++) { 135 | packedInt[i * 2] = i 136 | packedInt[i * 2 + 1] = (0x3ff00000 & (levels[i] << 20) | 0x0000ffff & ((1 - points[i * 2]) * 0xffff)) 137 | } 138 | 139 | // do native sort 140 | packed.sort() 141 | 142 | // unpack data back 143 | let sortedLevels = new Uint8Array(n) 144 | let sortedWeights = new Uint32Array(n) 145 | let sortedIds = new Uint32Array(n) 146 | let sortedPoints = new Float64Array(n * 2) 147 | for (let i = 0; i < n; i++) { 148 | let id = packedInt[(n - i - 1) * 2] 149 | sortedLevels[i] = levels[id] 150 | sortedWeights[i] = weights[id] 151 | sortedIds[i] = ids[id] 152 | sortedPoints[i * 2] = points[id * 2] 153 | sortedPoints[i * 2 + 1] = points[id * 2 + 1] 154 | } 155 | 156 | ids = sortedIds 157 | levels = sortedLevels 158 | points = sortedPoints 159 | weights = sortedWeights 160 | } 161 | 162 | */ 163 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tape') 4 | const cluster = require('./') 5 | const approxEqual = require('almost-equal') 6 | 7 | t('quad: offsets case', t => { 8 | let points = [.15,.8, .2,.15, .6,.6, .6,.45, .8,.1, .9,.6, .91,.61] 9 | let index = cluster(points, {bounds: [0,0,1,1]}) 10 | 11 | t.deepEqual(index.slice(), [0, 1,3,2, 4,5, 6]) 12 | 13 | t.end() 14 | }) 15 | 16 | t('quad: output container', t => { 17 | let points = [.15,.8, .2,.15, .6,.6, .6,.45, .8,.1, .9,.6, .91,.61] 18 | let arr = [] 19 | let index = cluster(points, {bounds: [0,0,1,1], output: arr}) 20 | 21 | t.deepEqual(arr.slice(), [0, 1,3,2, 4,5, 6]) 22 | t.equal(index, arr) 23 | 24 | t.end() 25 | }) 26 | 27 | 28 | t('quad: max depth', t => { 29 | let points = [] 30 | 31 | for (let i = 0; i < 1e4; i++) { 32 | points.push(0, 0) 33 | } 34 | 35 | let index = cluster(points) 36 | 37 | t.end() 38 | }) 39 | 40 | t('quad: lod method', t => { 41 | let points = [0,0, 1,1, 2,2, 3,3, 4,4, 5,5, 6,6, 7,7] 42 | 43 | let index = cluster(points) 44 | 45 | t.deepEqual(index.range({lod: true, d: 1e-10}), [[0,1], [1,3], [3,6], [6,8]]) 46 | t.deepEqual(index.range({lod: true, level: 400}), [[0,1], [1,3], [3,6], [6,8]]) 47 | t.deepEqual(index.range({lod: true}), [[0,1], [1,3], [3,6], [6,8]]) 48 | t.deepEqual(index.range([1,6], {lod: true}), [[0,1], [1,2], [3,4], [6,7]]) 49 | 50 | t.end() 51 | }) 52 | 53 | t('quad: selection', t => { 54 | let points = [0,0, 1,1, 2,2, 3,3, 4,4, 5,5, 6,6, 7,7] 55 | 56 | let index = cluster(points) 57 | 58 | t.deepEqual(index.range(), [0, 1, 2, 3, 4, 5, 6, 7]) 59 | t.deepEqual(index.range(1,1,6,6), [1, 2, 3, 4, 5, 6]) 60 | t.deepEqual(index.range(2,1,5,6), [2, 3, 4, 5]) 61 | t.deepEqual(index.range(1,2,6,5), [2, 3, 4, 5]) 62 | t.deepEqual(index.range(1,3,5,6), [3, 4, 5]) 63 | 64 | t.deepEqual(index.range(5,6,1,3), [3, 4, 5]) 65 | 66 | t.end() 67 | }) 68 | 69 | t.skip('quad: snap-points-2d cases', t => { 70 | function verifySnap(srcPoints) { 71 | let numPoints = srcPoints.length>>>1 72 | 73 | let {levels, ids, weights, points, bounds} = cluster(srcPoints) 74 | let npoints = points 75 | 76 | let sx = bounds[0] 77 | let sy = bounds[1] 78 | let sw = bounds[2] - bounds[0] 79 | let sh = bounds[3] - bounds[1] 80 | 81 | for(let i=0; i < numPoints; ++i) { 82 | let id = ids[i] 83 | t.ok(approxEqual(sx + sw*npoints[2*i], srcPoints[2*id], approxEqual.FLT_EPSILON), 84 | 'id perm ok: ' + id + ' ' + srcPoints[2*id] + ' = ' + (sx + sw*npoints[2*i])) 85 | t.ok(approxEqual(sy + sh*npoints[2*i+1], srcPoints[2*id+1], approxEqual.FLT_EPSILON), 'id perm ok: ' + id + ' ' + srcPoints[2*id+1] + ' = ' + (sy + sh*npoints[2*i+1])) 86 | } 87 | 88 | t.equals(levels[levels.length-1].offset, 0, 'last item') 89 | t.equals(levels[0].offset+levels[0].count, numPoints, 'first item') 90 | 91 | for(let i=0; i < levels.length; ++i) { 92 | let s = levels[i] 93 | let r = s.pixelSize 94 | let offs = s.offset 95 | let count = s.count 96 | 97 | if(i > 0) { 98 | t.equals(offs+count, levels[i-1].offset, 'offset for ' + i) 99 | t.ok(r < levels[i-1].pixelSize, 'test scales ok') 100 | } 101 | k_loop: 102 | for(let k=offs-1; k >= 0; --k) { 103 | let ax = npoints[2*k] 104 | let ay = npoints[2*k+1] 105 | 106 | let mind = Infinity 107 | 108 | for(let j=offs; j < offs+count; ++j) { 109 | let x = npoints[2*j] 110 | let y = npoints[2*j+1] 111 | 112 | mind = Math.min(mind, Math.max(Math.abs(ax-x), Math.abs(ay-y))) 113 | } 114 | 115 | t.ok(mind <= 2.0 * r, k + ':' + ax + ',' + ay + ' is not covered - closest pt = ' + mind) 116 | } 117 | } 118 | } 119 | 120 | verifySnap([ 121 | 1, 1, 122 | 2, 2, 123 | 3, 3, 124 | 4, 4, 125 | 5, 5 126 | ]) 127 | 128 | verifySnap([ 129 | 0,0, 130 | 0,0, 131 | 0,0, 132 | 0,0 133 | ]) 134 | 135 | verifySnap([ 136 | 1, 2, 137 | 2, 5, 138 | 3, 6, 139 | 4, -1 140 | ]) 141 | 142 | let pts = new Array(100) 143 | for(let i=0; i < 100; ++i) { 144 | pts[i] = Math.random() 145 | } 146 | verifySnap(pts) 147 | 148 | t.end() 149 | }) 150 | 151 | t.skip('quad: linear case', t => { 152 | let {levels, points, ids, weights} = cluster([1,1,2,2,3,3,4,4,5,5]) 153 | 154 | t.deepEqual(ids, [2, 4, 1, 3, 0]) 155 | t.deepEqual(points, [ 0.5, 0.5, 1, 1, 0.25, 0.25, 0.75, 0.75, 0, 0 ]) 156 | t.deepEqual(weights, [1, 1, 2, 2, 5]) 157 | t.deepEqual(levels, [ 158 | { count: 1, offset: 4, pixelSize: 2 }, 159 | { count: 2, offset: 2, pixelSize: 1 }, 160 | { count: 2, offset: 0, pixelSize: 0.5 } 161 | ]) 162 | 163 | t.end() 164 | }) 165 | 166 | t.skip('quad: no arguments', t => { 167 | let levels = cluster([0,0, 1,1, 2,2]) 168 | 169 | t.end() 170 | }) 171 | 172 | t.skip('quad: larger bounds', t => { 173 | let pos = [0,0, 1,1, 2,2, 3,3, 4,4] 174 | 175 | let {levels} = cluster(pos.slice(), { bounds: [0,0,4,4] }) 176 | t.deepEqual(levels, [ 177 | {pixelSize: 2, offset: 4, count: 1}, 178 | {pixelSize: 1, offset: 2, count: 2}, 179 | {pixelSize: 0.5, offset: 0, count: 2} 180 | ]) 181 | 182 | let index = cluster(pos.slice(), { bounds: [0,0,40,40] }) 183 | levels = index.levels 184 | 185 | t.deepEqual(levels, [ 186 | {pixelSize: 20, offset: 4, count: 1}, 187 | {pixelSize: 10, offset: 3, count: 1}, 188 | {pixelSize: 5, offset: 2, count: 1}, 189 | {pixelSize: 2.5, offset: 1, count: 1}, 190 | {pixelSize: 1.25, offset: 0, count: 1} 191 | ]) 192 | 193 | t.end() 194 | }) 195 | 196 | t.skip('quad: group id', t => { 197 | // TODO 198 | t.end() 199 | }) 200 | 201 | t.skip('kd: creates an index', t => { 202 | var points = [ 203 | 54,1, 97,21, 65,35, 33,54, 95,39, 54,3, 53,54, 84,72, 33,34, 43,15, 52,83, 81,23, 1,61, 38,74, 204 | 11,91, 24,56, 90,31, 25,57, 46,61, 29,69, 49,60, 4,98, 71,15, 60,25, 38,84, 52,38, 94,51, 13,25, 205 | 77,73, 88,87, 6,27, 58,22, 53,28, 27,91, 96,98, 93,14, 22,93, 45,94, 18,28, 35,15, 19,81, 20,81, 206 | 67,53, 43,3, 47,66, 48,34, 46,12, 32,38, 43,12, 39,94, 88,62, 66,14, 84,30, 72,81, 41,92, 26,4, 207 | 6,76, 47,21, 57,70, 71,82, 50,68, 96,18, 40,31, 78,53, 71,90, 32,14, 55,6, 32,88, 62,32, 21,67, 208 | 73,81, 44,64, 29,50, 70,5, 6,22, 68,3, 11,23, 20,42, 21,73, 63,86, 9,40, 99,2, 99,76, 56,77, 209 | 83,6, 21,72, 78,30, 75,53, 41,11, 95,20, 30,38, 96,82, 65,48, 33,18, 87,28, 10,10, 40,34, 210 | 10,20, 47,29, 46,78]; 211 | 212 | var ids = [ 213 | 97,74,95,30,77,38,76,27,80,55,72,90,88,48,43,46,65,39,62,93,9,96,47,8,3,12,15,14,21,41,36,40,69,56,85,78,17,71,44, 214 | 19,18,13,99,24,67,33,37,49,54,57,98,45,23,31,66,68,0,32,5,51,75,73,84,35,81,22,61,89,1,11,86,52,94,16,2,6,25,92, 215 | 42,20,60,58,83,79,64,10,59,53,26,87,4,63,50,7,28,82,70,29,34,91]; 216 | 217 | var coords = [ 218 | 10,20,6,22,10,10,6,27,20,42,18,28,11,23,13,25,9,40,26,4,29,50,30,38,41,11,43,12,43,3,46,12,32,14,35,15,40,31,33,18, 219 | 43,15,40,34,32,38,33,34,33,54,1,61,24,56,11,91,4,98,20,81,22,93,19,81,21,67,6,76,21,72,21,73,25,57,44,64,47,66,29, 220 | 69,46,61,38,74,46,78,38,84,32,88,27,91,45,94,39,94,41,92,47,21,47,29,48,34,60,25,58,22,55,6,62,32,54,1,53,28,54,3, 221 | 66,14,68,3,70,5,83,6,93,14,99,2,71,15,96,18,95,20,97,21,81,23,78,30,84,30,87,28,90,31,65,35,53,54,52,38,65,48,67, 222 | 53,49,60,50,68,57,70,56,77,63,86,71,90,52,83,71,82,72,81,94,51,75,53,95,39,78,53,88,62,84,72,77,73,99,76,73,81,88, 223 | 87,96,98,96,82]; 224 | var index = cluster(points, { nodeSize:10, type: 'kd', sort: false }); 225 | 226 | t.same(index.ids, ids, 'ids are kd-sorted'); 227 | // t.same(index.points, points, 'coords are kd-sorted'); 228 | 229 | t.end(); 230 | }) 231 | 232 | t.skip('kd: range search', t => { 233 | var pts = points 234 | 235 | var index = cluster(pts, {nodeSize:10, type: 'kd'}); 236 | 237 | var result = index.range(20, 30, 50, 70); 238 | 239 | t.same(result, [60,20,45,3,17,71,44,19,18,15,69,90,62,96,47,8,77,72], 'returns ids'); 240 | 241 | for (var i = 0; i < result.length; i++) { 242 | var p = pts[result[i]]; 243 | if (p[0] < 20 || p[0] > 50 || p[1] < 30 || p[1] > 70) 244 | t.fail('result point in range'); 245 | } 246 | t.pass('result points in range'); 247 | 248 | for (i = 0; i < ids.length; i++) { 249 | p = pts[ids[i]]; 250 | if (result.indexOf(ids[i]) < 0 && p[0] >= 20 && p[0] <= 50 && p[1] >= 30 && p[1] <= 70) 251 | t.fail('outside point not in range'); 252 | } 253 | t.pass('outside points not in range'); 254 | 255 | t.end(); 256 | }) 257 | 258 | t.skip('kd: radius search', t => { 259 | var pts = points 260 | 261 | var index = cluster(pts, {nodeSize:10, type: 'kd'}); 262 | 263 | var qp = [50, 50]; 264 | var r = 20; 265 | var r2 = 20 * 20; 266 | 267 | var result = index.within(qp[0], qp[1], r); 268 | 269 | t.same(result, [60,6,25,92,42,20,45,3,71,44,18,96], 'returns ids'); 270 | 271 | for (var i = 0; i < result.length; i++) { 272 | var p = pts[result[i]]; 273 | if (sqDist(p, qp) > r2) t.fail('result point in range'); 274 | } 275 | t.pass('result points in range'); 276 | 277 | for (i = 0; i < ids.length; i++) { 278 | p = pts[ids[i]]; 279 | if (result.indexOf(ids[i]) < 0 && sqDist(p, qp) <= r2) 280 | t.fail('outside point not in range'); 281 | } 282 | t.pass('outside points not in range'); 283 | 284 | t.end(); 285 | }) 286 | 287 | t('performance', t => { 288 | let N = 1e6 289 | let points = new Float64Array(N) 290 | let ids = new Uint32Array(N) 291 | 292 | for (let i = 0; i < N; i++) { 293 | points[i] = Math.random() 294 | ids[i] = i 295 | } 296 | 297 | console.time(1) 298 | cluster(points, {sort: false}) 299 | console.timeEnd(1) 300 | 301 | t.end() 302 | }) 303 | 304 | --------------------------------------------------------------------------------