@flowmap.gl/data 8.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +199 -0
- package/dist/FlowMapAggregateAccessors.d.ts +15 -0
- package/dist/FlowMapAggregateAccessors.d.ts.map +1 -0
- package/dist/FlowMapAggregateAccessors.js +43 -0
- package/dist/FlowMapSelectors.d.ts +156 -0
- package/dist/FlowMapSelectors.d.ts.map +1 -0
- package/dist/FlowMapSelectors.js +831 -0
- package/dist/FlowMapState.d.ts +24 -0
- package/dist/FlowMapState.d.ts.map +1 -0
- package/dist/FlowMapState.js +2 -0
- package/dist/cluster/ClusterIndex.d.ts +42 -0
- package/dist/cluster/ClusterIndex.d.ts.map +1 -0
- package/dist/cluster/ClusterIndex.js +178 -0
- package/dist/cluster/cluster.d.ts +31 -0
- package/dist/cluster/cluster.d.ts.map +1 -0
- package/dist/cluster/cluster.js +206 -0
- package/dist/colors.d.ts +103 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +441 -0
- package/dist/getViewStateForLocations.d.ts +16 -0
- package/dist/getViewStateForLocations.d.ts.map +1 -0
- package/dist/getViewStateForLocations.js +30 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/provider/FlowMapDataProvider.d.ts +16 -0
- package/dist/provider/FlowMapDataProvider.d.ts.map +1 -0
- package/dist/provider/FlowMapDataProvider.js +17 -0
- package/dist/provider/LocalFlowMapDataProvider.d.ts +20 -0
- package/dist/provider/LocalFlowMapDataProvider.d.ts.map +1 -0
- package/dist/provider/LocalFlowMapDataProvider.js +87 -0
- package/dist/time.d.ts +24 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +126 -0
- package/dist/types.d.ts +116 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +4 -0
- package/package.json +48 -0
- package/src/FlowMapAggregateAccessors.ts +60 -0
- package/src/FlowMapSelectors.ts +1407 -0
- package/src/FlowMapState.ts +26 -0
- package/src/cluster/ClusterIndex.ts +266 -0
- package/src/cluster/cluster.ts +299 -0
- package/src/colors.ts +723 -0
- package/src/getViewStateForLocations.ts +64 -0
- package/src/index.ts +10 -0
- package/src/provider/FlowMapDataProvider.ts +63 -0
- package/src/provider/LocalFlowMapDataProvider.ts +108 -0
- package/src/time.ts +160 -0
- package/src/types.ts +162 -0
- package/src/util.ts +3 -0
- package/tsconfig.json +11 -0
- package/typings.d.ts +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {LocationFilterMode, ViewportProps} from './types';
|
|
2
|
+
|
|
3
|
+
export interface FilterState {
|
|
4
|
+
selectedLocations: string[] | undefined;
|
|
5
|
+
selectedTimeRange: [Date, Date] | undefined;
|
|
6
|
+
locationFilterMode: LocationFilterMode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SettingsState {
|
|
10
|
+
animationEnabled: boolean;
|
|
11
|
+
fadeEnabled: boolean;
|
|
12
|
+
locationTotalsEnabled: boolean;
|
|
13
|
+
adaptiveScalesEnabled: boolean;
|
|
14
|
+
clusteringEnabled: boolean;
|
|
15
|
+
clusteringAuto: boolean;
|
|
16
|
+
clusteringLevel?: number;
|
|
17
|
+
darkMode: boolean;
|
|
18
|
+
fadeAmount: number;
|
|
19
|
+
colorScheme: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FlowMapState {
|
|
23
|
+
filterState: FilterState;
|
|
24
|
+
settingsState: SettingsState;
|
|
25
|
+
viewport: ViewportProps;
|
|
26
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2022 FlowmapBlue
|
|
3
|
+
* Copyright 2018-2020 Teralytics, modified by FlowmapBlue
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
AggregateFlow,
|
|
21
|
+
Cluster,
|
|
22
|
+
ClusterLevels,
|
|
23
|
+
ClusterNode,
|
|
24
|
+
FlowAccessors,
|
|
25
|
+
FlowCountsMapReduce,
|
|
26
|
+
isCluster,
|
|
27
|
+
} from './../types';
|
|
28
|
+
import {ascending, bisectLeft, extent} from 'd3-array';
|
|
29
|
+
|
|
30
|
+
export type LocationWeightGetter = (id: string) => number;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A data structure representing the cluster levels for efficient flow aggregation.
|
|
34
|
+
*/
|
|
35
|
+
export interface ClusterIndex<F> {
|
|
36
|
+
availableZoomLevels: number[];
|
|
37
|
+
getClusterById: (clusterId: string) => Cluster | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* List the nodes on the given zoom level.
|
|
40
|
+
*/
|
|
41
|
+
getClusterNodesFor: (zoom: number | undefined) => ClusterNode[] | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Get the min zoom level on which the location is not clustered.
|
|
44
|
+
*/
|
|
45
|
+
getMinZoomForLocation: (locationId: string) => number;
|
|
46
|
+
/**
|
|
47
|
+
* List the IDs of all locations in the cluster (leaves of the subtree starting in the cluster).
|
|
48
|
+
*/
|
|
49
|
+
expandCluster: (cluster: Cluster, targetZoom?: number) => string[];
|
|
50
|
+
/**
|
|
51
|
+
* Find the cluster the given location is residing in on the specified zoom level.
|
|
52
|
+
*/
|
|
53
|
+
findClusterFor: (locationId: string, zoom: number) => string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Aggregate flows for the specified zoom level.
|
|
56
|
+
*/
|
|
57
|
+
aggregateFlows: (
|
|
58
|
+
flows: F[],
|
|
59
|
+
zoom: number,
|
|
60
|
+
{getFlowOriginId, getFlowDestId, getFlowMagnitude}: FlowAccessors<F>,
|
|
61
|
+
options?: {
|
|
62
|
+
flowCountsMapReduce?: FlowCountsMapReduce<F>;
|
|
63
|
+
},
|
|
64
|
+
) => (F | AggregateFlow)[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build ClusterIndex from the given cluster hierarchy
|
|
69
|
+
*/
|
|
70
|
+
export function buildIndex<F>(clusterLevels: ClusterLevels): ClusterIndex<F> {
|
|
71
|
+
const nodesByZoom = new Map<number, ClusterNode[]>();
|
|
72
|
+
const clustersById = new Map<string, Cluster>();
|
|
73
|
+
const minZoomByLocationId = new Map<string, number>();
|
|
74
|
+
for (const {zoom, nodes} of clusterLevels) {
|
|
75
|
+
nodesByZoom.set(zoom, nodes);
|
|
76
|
+
for (const node of nodes) {
|
|
77
|
+
if (isCluster(node)) {
|
|
78
|
+
clustersById.set(node.id, node);
|
|
79
|
+
} else {
|
|
80
|
+
const {id} = node;
|
|
81
|
+
const mz = minZoomByLocationId.get(id);
|
|
82
|
+
if (mz == null || mz > zoom) {
|
|
83
|
+
minZoomByLocationId.set(id, zoom);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [minZoom, maxZoom] = extent(clusterLevels, (cl) => cl.zoom);
|
|
90
|
+
if (minZoom == null || maxZoom == null) {
|
|
91
|
+
throw new Error('Could not determine minZoom or maxZoom');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const leavesToClustersByZoom = new Map<number, Map<string, Cluster>>();
|
|
95
|
+
|
|
96
|
+
for (const cluster of clustersById.values()) {
|
|
97
|
+
const {zoom} = cluster;
|
|
98
|
+
let leavesToClusters = leavesToClustersByZoom.get(zoom);
|
|
99
|
+
if (!leavesToClusters) {
|
|
100
|
+
leavesToClusters = new Map<string, Cluster>();
|
|
101
|
+
leavesToClustersByZoom.set(zoom, leavesToClusters);
|
|
102
|
+
}
|
|
103
|
+
visitClusterLeaves(cluster, (leafId) => {
|
|
104
|
+
leavesToClusters?.set(leafId, cluster);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function visitClusterLeaves(cluster: Cluster, visit: (id: string) => void) {
|
|
109
|
+
for (const childId of cluster.children) {
|
|
110
|
+
const child = clustersById.get(childId);
|
|
111
|
+
if (child) {
|
|
112
|
+
visitClusterLeaves(child, visit);
|
|
113
|
+
} else {
|
|
114
|
+
visit(childId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const expandCluster = (cluster: Cluster, targetZoom: number = maxZoom) => {
|
|
120
|
+
const ids: string[] = [];
|
|
121
|
+
const visit = (c: Cluster, expandedIds: string[]) => {
|
|
122
|
+
if (targetZoom > c.zoom) {
|
|
123
|
+
for (const childId of c.children) {
|
|
124
|
+
const child = clustersById.get(childId);
|
|
125
|
+
if (child) {
|
|
126
|
+
visit(child, expandedIds);
|
|
127
|
+
} else {
|
|
128
|
+
expandedIds.push(childId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
expandedIds.push(c.id);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
visit(cluster, ids);
|
|
136
|
+
return ids;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
function findClusterFor(locationId: string, zoom: number) {
|
|
140
|
+
const leavesToClusters = leavesToClustersByZoom.get(zoom);
|
|
141
|
+
if (!leavesToClusters) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const cluster = leavesToClusters.get(locationId);
|
|
145
|
+
return cluster ? cluster.id : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const availableZoomLevels = clusterLevels
|
|
149
|
+
.map((cl) => +cl.zoom)
|
|
150
|
+
.sort((a, b) => ascending(a, b));
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
availableZoomLevels,
|
|
154
|
+
|
|
155
|
+
getClusterNodesFor: (zoom) => {
|
|
156
|
+
if (zoom === undefined) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
return nodesByZoom.get(zoom);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
getClusterById: (clusterId) => clustersById.get(clusterId),
|
|
163
|
+
|
|
164
|
+
getMinZoomForLocation: (locationId) =>
|
|
165
|
+
minZoomByLocationId.get(locationId) || minZoom,
|
|
166
|
+
|
|
167
|
+
expandCluster,
|
|
168
|
+
|
|
169
|
+
findClusterFor,
|
|
170
|
+
|
|
171
|
+
aggregateFlows: (
|
|
172
|
+
flows,
|
|
173
|
+
zoom,
|
|
174
|
+
{getFlowOriginId, getFlowDestId, getFlowMagnitude},
|
|
175
|
+
options = {},
|
|
176
|
+
) => {
|
|
177
|
+
if (zoom > maxZoom) {
|
|
178
|
+
return flows;
|
|
179
|
+
}
|
|
180
|
+
const result: (F | AggregateFlow)[] = [];
|
|
181
|
+
const aggFlowsByKey = new Map<string, AggregateFlow>();
|
|
182
|
+
const makeKey = (origin: string, dest: string) => `${origin}:${dest}`;
|
|
183
|
+
const {
|
|
184
|
+
flowCountsMapReduce = {
|
|
185
|
+
map: getFlowMagnitude,
|
|
186
|
+
reduce: (acc: any, count: number) => (acc || 0) + count,
|
|
187
|
+
},
|
|
188
|
+
} = options;
|
|
189
|
+
for (const flow of flows) {
|
|
190
|
+
const origin = getFlowOriginId(flow);
|
|
191
|
+
const dest = getFlowDestId(flow);
|
|
192
|
+
const originCluster = findClusterFor(origin, zoom) || origin;
|
|
193
|
+
const destCluster = findClusterFor(dest, zoom) || dest;
|
|
194
|
+
const key = makeKey(originCluster, destCluster);
|
|
195
|
+
if (originCluster === origin && destCluster === dest) {
|
|
196
|
+
result.push(flow);
|
|
197
|
+
} else {
|
|
198
|
+
let aggregateFlow = aggFlowsByKey.get(key);
|
|
199
|
+
if (!aggregateFlow) {
|
|
200
|
+
aggregateFlow = {
|
|
201
|
+
origin: originCluster,
|
|
202
|
+
dest: destCluster,
|
|
203
|
+
count: flowCountsMapReduce.map(flow),
|
|
204
|
+
aggregate: true,
|
|
205
|
+
};
|
|
206
|
+
result.push(aggregateFlow);
|
|
207
|
+
aggFlowsByKey.set(key, aggregateFlow);
|
|
208
|
+
} else {
|
|
209
|
+
aggregateFlow.count = flowCountsMapReduce.reduce(
|
|
210
|
+
aggregateFlow.count,
|
|
211
|
+
flowCountsMapReduce.map(flow),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return result;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function makeLocationWeightGetter<F>(
|
|
222
|
+
flows: F[],
|
|
223
|
+
{getFlowOriginId, getFlowDestId, getFlowMagnitude}: FlowAccessors<F>,
|
|
224
|
+
): LocationWeightGetter {
|
|
225
|
+
const locationTotals = {
|
|
226
|
+
incoming: new Map<string, number>(),
|
|
227
|
+
outgoing: new Map<string, number>(),
|
|
228
|
+
};
|
|
229
|
+
for (const flow of flows) {
|
|
230
|
+
const origin = getFlowOriginId(flow);
|
|
231
|
+
const dest = getFlowDestId(flow);
|
|
232
|
+
const count = getFlowMagnitude(flow);
|
|
233
|
+
locationTotals.incoming.set(
|
|
234
|
+
dest,
|
|
235
|
+
(locationTotals.incoming.get(dest) || 0) + count,
|
|
236
|
+
);
|
|
237
|
+
locationTotals.outgoing.set(
|
|
238
|
+
origin,
|
|
239
|
+
(locationTotals.outgoing.get(origin) || 0) + count,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return (id: string) =>
|
|
243
|
+
Math.max(
|
|
244
|
+
Math.abs(locationTotals.incoming.get(id) || 0),
|
|
245
|
+
Math.abs(locationTotals.outgoing.get(id) || 0),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @param availableZoomLevels Must be sorted in ascending order
|
|
251
|
+
* @param targetZoom
|
|
252
|
+
*/
|
|
253
|
+
export function findAppropriateZoomLevel(
|
|
254
|
+
availableZoomLevels: number[],
|
|
255
|
+
targetZoom: number,
|
|
256
|
+
) {
|
|
257
|
+
if (!availableZoomLevels.length) {
|
|
258
|
+
throw new Error('No available zoom levels');
|
|
259
|
+
}
|
|
260
|
+
return availableZoomLevels[
|
|
261
|
+
Math.min(
|
|
262
|
+
bisectLeft(availableZoomLevels, Math.floor(targetZoom)),
|
|
263
|
+
availableZoomLevels.length - 1,
|
|
264
|
+
)
|
|
265
|
+
];
|
|
266
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2022 FlowmapBlue
|
|
3
|
+
* Copyright 2018-2020 Teralytics, modified by FlowmapBlue
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The code in this file is a based on https://github.com/mapbox/supercluster
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ISC License
|
|
24
|
+
//
|
|
25
|
+
// Copyright (c) 2016, Mapbox
|
|
26
|
+
//
|
|
27
|
+
// Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
28
|
+
// with or without fee is hereby granted, provided that the above copyright notice
|
|
29
|
+
// and this permission notice appear in all copies.
|
|
30
|
+
//
|
|
31
|
+
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
32
|
+
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
33
|
+
// FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
34
|
+
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
|
35
|
+
// OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
|
36
|
+
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
|
37
|
+
// THIS SOFTWARE.
|
|
38
|
+
|
|
39
|
+
import {rollup} from 'd3-array';
|
|
40
|
+
import KDBush from 'kdbush';
|
|
41
|
+
import {LocationWeightGetter} from './ClusterIndex';
|
|
42
|
+
import {Cluster, ClusterLevel, ClusterNode, LocationAccessors} from '../types';
|
|
43
|
+
|
|
44
|
+
export interface Options {
|
|
45
|
+
minZoom: number; // min zoom to generate clusters on
|
|
46
|
+
maxZoom: number; // max zoom level to cluster the points on
|
|
47
|
+
radius: number; // cluster radius in pixels
|
|
48
|
+
extent: number; // tile extent (radius is calculated relative to it)
|
|
49
|
+
nodeSize: number; // size of the KD-tree leaf node, affects performance
|
|
50
|
+
makeClusterName: (id: number, numPoints: number) => string | undefined;
|
|
51
|
+
makeClusterId: (id: number) => string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultOptions: Options = {
|
|
55
|
+
minZoom: 0,
|
|
56
|
+
maxZoom: 16,
|
|
57
|
+
radius: 40,
|
|
58
|
+
extent: 512,
|
|
59
|
+
nodeSize: 64,
|
|
60
|
+
makeClusterName: (id: number, numPoints: number) => undefined,
|
|
61
|
+
makeClusterId: (id: number) => `{[${id}]}`,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
interface BasePoint {
|
|
65
|
+
x: number; // projected point coordinates
|
|
66
|
+
y: number;
|
|
67
|
+
weight: number;
|
|
68
|
+
zoom: number; // the last zoom the point was processed at
|
|
69
|
+
parentId: number; // parent cluster id
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface LeafPoint extends BasePoint {
|
|
73
|
+
index: number; // index of the source feature in the original input array,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface ClusterPoint extends BasePoint {
|
|
77
|
+
id: number;
|
|
78
|
+
numPoints: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type Point = LeafPoint | ClusterPoint;
|
|
82
|
+
|
|
83
|
+
export function isLeafPoint(p: Point): p is LeafPoint {
|
|
84
|
+
const {index} = p as LeafPoint;
|
|
85
|
+
return index != null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isClusterPoint(p: Point): p is ClusterPoint {
|
|
89
|
+
const {id} = p as ClusterPoint;
|
|
90
|
+
return id != null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type ZoomLevelKDBush = any;
|
|
94
|
+
|
|
95
|
+
export function clusterLocations<L>(
|
|
96
|
+
locations: L[],
|
|
97
|
+
locationAccessors: LocationAccessors<L>,
|
|
98
|
+
getLocationWeight: LocationWeightGetter,
|
|
99
|
+
options?: Partial<Options>,
|
|
100
|
+
): ClusterLevel[] {
|
|
101
|
+
const {getLocationCentroid, getLocationId} = locationAccessors;
|
|
102
|
+
const opts = {
|
|
103
|
+
...defaultOptions,
|
|
104
|
+
...options,
|
|
105
|
+
};
|
|
106
|
+
const {minZoom, maxZoom, nodeSize, makeClusterName, makeClusterId} = opts;
|
|
107
|
+
|
|
108
|
+
const trees = new Array<ZoomLevelKDBush>(maxZoom + 1);
|
|
109
|
+
|
|
110
|
+
// generate a cluster object for each point and index input points into a KD-tree
|
|
111
|
+
let clusters = new Array<Point>();
|
|
112
|
+
for (let i = 0; i < locations.length; i++) {
|
|
113
|
+
const [x, y] = getLocationCentroid(locations[i]);
|
|
114
|
+
clusters.push({
|
|
115
|
+
x: lngX(x), // projected point coordinates
|
|
116
|
+
y: latY(y),
|
|
117
|
+
weight: getLocationWeight(getLocationId(locations[i])),
|
|
118
|
+
zoom: Infinity, // the last zoom the point was processed at
|
|
119
|
+
index: i, // index of the source feature in the original input array,
|
|
120
|
+
parentId: -1, // parent cluster id
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
trees[maxZoom + 1] = new KDBush(clusters, getX, getY, nodeSize, Float32Array);
|
|
124
|
+
|
|
125
|
+
// cluster points on max zoom, then cluster the results on previous zoom, etc.;
|
|
126
|
+
// results in a cluster hierarchy across zoom levels
|
|
127
|
+
for (let z = maxZoom; z >= minZoom; z--) {
|
|
128
|
+
// create a new set of clusters for the zoom and index them with a KD-tree
|
|
129
|
+
clusters = cluster(clusters, z, trees[z + 1], opts);
|
|
130
|
+
trees[z] = new KDBush(clusters, getX, getY, nodeSize, Float32Array);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (trees.length === 0) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
const numbersOfClusters = trees.map((d) => d.points.length);
|
|
137
|
+
const maxAvailZoom = numbersOfClusters.indexOf(
|
|
138
|
+
numbersOfClusters[numbersOfClusters.length - 1],
|
|
139
|
+
);
|
|
140
|
+
const minAvailZoom = Math.min(
|
|
141
|
+
maxAvailZoom,
|
|
142
|
+
numbersOfClusters.lastIndexOf(numbersOfClusters[0]),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const clusterLevels = new Array<ClusterLevel>();
|
|
146
|
+
for (let zoom = minAvailZoom; zoom <= maxAvailZoom; zoom++) {
|
|
147
|
+
let childrenByParent: Map<number, string[]> | undefined;
|
|
148
|
+
const tree = trees[zoom];
|
|
149
|
+
if (zoom < maxAvailZoom) {
|
|
150
|
+
childrenByParent = rollup<Point, string[], number>(
|
|
151
|
+
trees[zoom + 1].points,
|
|
152
|
+
(points: any[]) =>
|
|
153
|
+
points.map((p: any) =>
|
|
154
|
+
p.id ? makeClusterId(p.id) : getLocationId(locations[p.index]),
|
|
155
|
+
),
|
|
156
|
+
(point: any) => point.parentId,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const nodes: ClusterNode[] = [];
|
|
161
|
+
for (const point of tree.points) {
|
|
162
|
+
const {x, y, numPoints} = point;
|
|
163
|
+
if (isLeafPoint(point)) {
|
|
164
|
+
const location = locations[point.index];
|
|
165
|
+
nodes.push({
|
|
166
|
+
id: getLocationId(location),
|
|
167
|
+
zoom,
|
|
168
|
+
centroid: getLocationCentroid(location),
|
|
169
|
+
});
|
|
170
|
+
} else if (isClusterPoint(point)) {
|
|
171
|
+
const {id} = point;
|
|
172
|
+
const children = childrenByParent && childrenByParent.get(id);
|
|
173
|
+
if (!children) {
|
|
174
|
+
throw new Error(`Cluster ${id} doesn't have children`);
|
|
175
|
+
}
|
|
176
|
+
nodes.push({
|
|
177
|
+
id: makeClusterId(id),
|
|
178
|
+
name: makeClusterName(id, numPoints),
|
|
179
|
+
zoom,
|
|
180
|
+
centroid: [xLng(x), yLat(y)] as [number, number],
|
|
181
|
+
children,
|
|
182
|
+
} as Cluster);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
clusterLevels.push({
|
|
186
|
+
zoom,
|
|
187
|
+
nodes,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return clusterLevels;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function createCluster(
|
|
194
|
+
x: number,
|
|
195
|
+
y: number,
|
|
196
|
+
id: number,
|
|
197
|
+
numPoints: number,
|
|
198
|
+
weight: number,
|
|
199
|
+
): ClusterPoint {
|
|
200
|
+
return {
|
|
201
|
+
x, // weighted cluster center
|
|
202
|
+
y,
|
|
203
|
+
zoom: Infinity, // the last zoom the cluster was processed at
|
|
204
|
+
id, // encodes index of the first child of the cluster and its zoom level
|
|
205
|
+
parentId: -1, // parent cluster id
|
|
206
|
+
numPoints,
|
|
207
|
+
weight,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cluster(
|
|
212
|
+
points: Point[],
|
|
213
|
+
zoom: number,
|
|
214
|
+
tree: ZoomLevelKDBush,
|
|
215
|
+
options: Options,
|
|
216
|
+
) {
|
|
217
|
+
const clusters: Point[] = [];
|
|
218
|
+
const {radius, extent} = options;
|
|
219
|
+
const r = radius / (extent * Math.pow(2, zoom));
|
|
220
|
+
|
|
221
|
+
// loop through each point
|
|
222
|
+
for (let i = 0; i < points.length; i++) {
|
|
223
|
+
const p = points[i];
|
|
224
|
+
// if we've already visited the point at this zoom level, skip it
|
|
225
|
+
if (p.zoom <= zoom) {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
p.zoom = zoom;
|
|
229
|
+
|
|
230
|
+
// find all nearby points
|
|
231
|
+
const neighborIds = tree.within(p.x, p.y, r);
|
|
232
|
+
|
|
233
|
+
let weight = p.weight || 1;
|
|
234
|
+
let numPoints = isClusterPoint(p) ? p.numPoints : 1;
|
|
235
|
+
let wx = p.x * weight;
|
|
236
|
+
let wy = p.y * weight;
|
|
237
|
+
|
|
238
|
+
// encode both zoom and point index on which the cluster originated
|
|
239
|
+
const id = (i << 5) + (zoom + 1);
|
|
240
|
+
|
|
241
|
+
for (const neighborId of neighborIds) {
|
|
242
|
+
const b = tree.points[neighborId];
|
|
243
|
+
// filter out neighbors that are already processed
|
|
244
|
+
if (b.zoom <= zoom) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
b.zoom = zoom; // save the zoom (so it doesn't get processed twice)
|
|
248
|
+
|
|
249
|
+
const weight2 = b.weight || 1;
|
|
250
|
+
const numPoints2 = b.numPoints || 1;
|
|
251
|
+
wx += b.x * weight2; // accumulate coordinates for calculating weighted center
|
|
252
|
+
wy += b.y * weight2;
|
|
253
|
+
|
|
254
|
+
weight += weight2;
|
|
255
|
+
numPoints += numPoints2;
|
|
256
|
+
b.parentId = id;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (numPoints === 1) {
|
|
260
|
+
clusters.push(p);
|
|
261
|
+
} else {
|
|
262
|
+
p.parentId = id;
|
|
263
|
+
clusters.push(
|
|
264
|
+
createCluster(wx / weight, wy / weight, id, numPoints, weight),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return clusters;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// spherical mercator to longitude/latitude
|
|
273
|
+
function xLng(x: number) {
|
|
274
|
+
return (x - 0.5) * 360;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function yLat(y: number) {
|
|
278
|
+
const y2 = ((180 - y * 360) * Math.PI) / 180;
|
|
279
|
+
return (360 * Math.atan(Math.exp(y2))) / Math.PI - 90;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// longitude/latitude to spherical mercator in [0..1] range
|
|
283
|
+
function lngX(lng: number) {
|
|
284
|
+
return lng / 360 + 0.5;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function latY(lat: number) {
|
|
288
|
+
const sin = Math.sin((lat * Math.PI) / 180);
|
|
289
|
+
const y = 0.5 - (0.25 * Math.log((1 + sin) / (1 - sin))) / Math.PI;
|
|
290
|
+
return y < 0 ? 0 : y > 1 ? 1 : y;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function getX(p: Point) {
|
|
294
|
+
return p.x;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getY(p: Point) {
|
|
298
|
+
return p.y;
|
|
299
|
+
}
|