@fscharter/flowmap-data 8.0.2-fsc.1
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/dist/FlowmapAggregateAccessors.d.ts +16 -0
- package/dist/FlowmapAggregateAccessors.d.ts.map +1 -0
- package/dist/FlowmapAggregateAccessors.js +53 -0
- package/dist/FlowmapSelectors.d.ts +143 -0
- package/dist/FlowmapSelectors.d.ts.map +1 -0
- package/dist/FlowmapSelectors.js +881 -0
- package/dist/FlowmapState.d.ts +31 -0
- package/dist/FlowmapState.d.ts.map +1 -0
- package/dist/FlowmapState.js +7 -0
- package/dist/cluster/ClusterIndex.d.ts +42 -0
- package/dist/cluster/ClusterIndex.d.ts.map +1 -0
- package/dist/cluster/ClusterIndex.js +166 -0
- package/dist/cluster/cluster.d.ts +51 -0
- package/dist/cluster/cluster.d.ts.map +1 -0
- package/dist/cluster/cluster.js +267 -0
- package/dist/colors.d.ts +103 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +487 -0
- package/dist/getViewStateForLocations.d.ts +23 -0
- package/dist/getViewStateForLocations.d.ts.map +1 -0
- package/dist/getViewStateForLocations.js +54 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/provider/FlowmapDataProvider.d.ts +21 -0
- package/dist/provider/FlowmapDataProvider.d.ts.map +1 -0
- package/dist/provider/FlowmapDataProvider.js +22 -0
- package/dist/provider/LocalFlowmapDataProvider.d.ts +31 -0
- package/dist/provider/LocalFlowmapDataProvider.d.ts.map +1 -0
- package/dist/provider/LocalFlowmapDataProvider.js +115 -0
- package/dist/selector-functions.d.ts +10 -0
- package/dist/selector-functions.d.ts.map +1 -0
- package/dist/selector-functions.js +65 -0
- package/dist/time.d.ts +24 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +131 -0
- package/dist/types.d.ts +120 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +28 -0
- package/dist/util.d.ts +5 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +16 -0
- package/package.json +48 -0
- package/src/FlowmapAggregateAccessors.ts +76 -0
- package/src/FlowmapSelectors.ts +1539 -0
- package/src/FlowmapState.ts +40 -0
- package/src/cluster/ClusterIndex.ts +261 -0
- package/src/cluster/cluster.ts +394 -0
- package/src/colors.ts +771 -0
- package/src/getViewStateForLocations.ts +86 -0
- package/src/index.ts +19 -0
- package/src/provider/FlowmapDataProvider.ts +81 -0
- package/src/provider/LocalFlowmapDataProvider.ts +185 -0
- package/src/selector-functions.ts +93 -0
- package/src/time.ts +166 -0
- package/src/types.ts +172 -0
- package/src/util.ts +17 -0
- package/tsconfig.json +11 -0
- package/typings.d.ts +1 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Flowmap.gl contributors
|
|
3
|
+
* Copyright (c) 2018-2020 Teralytics
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {LocationFilterMode, ViewportProps} from './types';
|
|
8
|
+
|
|
9
|
+
export type FlowEndpointsInViewportMode = 'any' | 'both';
|
|
10
|
+
|
|
11
|
+
export interface FilterState {
|
|
12
|
+
selectedLocations?: (string | number)[];
|
|
13
|
+
locationFilterMode?: LocationFilterMode;
|
|
14
|
+
selectedTimeRange?: [Date, Date];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SettingsState {
|
|
18
|
+
animationEnabled: boolean;
|
|
19
|
+
fadeEnabled: boolean;
|
|
20
|
+
fadeOpacityEnabled: boolean;
|
|
21
|
+
locationsEnabled: boolean;
|
|
22
|
+
locationTotalsEnabled: boolean;
|
|
23
|
+
locationLabelsEnabled: boolean;
|
|
24
|
+
adaptiveScalesEnabled: boolean;
|
|
25
|
+
clusteringEnabled: boolean;
|
|
26
|
+
clusteringAuto: boolean;
|
|
27
|
+
clusteringLevel?: number;
|
|
28
|
+
darkMode: boolean;
|
|
29
|
+
fadeAmount: number;
|
|
30
|
+
colorScheme: string | string[] | undefined;
|
|
31
|
+
highlightColor: string;
|
|
32
|
+
maxTopFlowsDisplayNum: number;
|
|
33
|
+
flowEndpointsInViewportMode: FlowEndpointsInViewportMode;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FlowmapState {
|
|
37
|
+
filter?: FilterState;
|
|
38
|
+
settings: SettingsState;
|
|
39
|
+
viewport: ViewportProps;
|
|
40
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Flowmap.gl contributors
|
|
3
|
+
* Copyright (c) 2018-2020 Teralytics
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
AggregateFlow,
|
|
9
|
+
Cluster,
|
|
10
|
+
ClusterLevels,
|
|
11
|
+
ClusterNode,
|
|
12
|
+
FlowAccessors,
|
|
13
|
+
FlowCountsMapReduce,
|
|
14
|
+
isCluster,
|
|
15
|
+
} from './../types';
|
|
16
|
+
import {ascending, bisectLeft, extent} from 'd3-array';
|
|
17
|
+
|
|
18
|
+
export type LocationWeightGetter = (id: string | number) => number;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A data structure representing the cluster levels for efficient flow aggregation.
|
|
22
|
+
*/
|
|
23
|
+
export interface ClusterIndex<F> {
|
|
24
|
+
availableZoomLevels: number[];
|
|
25
|
+
getClusterById: (clusterId: string | number) => Cluster | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* List the nodes on the given zoom level.
|
|
28
|
+
*/
|
|
29
|
+
getClusterNodesFor: (zoom: number | undefined) => ClusterNode[] | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Get the min zoom level on which the location is not clustered.
|
|
32
|
+
*/
|
|
33
|
+
getMinZoomForLocation: (locationId: string | number) => number;
|
|
34
|
+
/**
|
|
35
|
+
* List the IDs of all locations in the cluster (leaves of the subtree starting in the cluster).
|
|
36
|
+
*/
|
|
37
|
+
expandCluster: (cluster: Cluster, targetZoom?: number) => string[];
|
|
38
|
+
/**
|
|
39
|
+
* Find the cluster the given location is residing in on the specified zoom level.
|
|
40
|
+
*/
|
|
41
|
+
findClusterFor: (
|
|
42
|
+
locationId: string | number,
|
|
43
|
+
zoom: number,
|
|
44
|
+
) => string | number | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Aggregate flows for the specified zoom level.
|
|
47
|
+
*/
|
|
48
|
+
aggregateFlows: (
|
|
49
|
+
flows: F[],
|
|
50
|
+
zoom: number,
|
|
51
|
+
{getFlowOriginId, getFlowDestId, getFlowMagnitude}: FlowAccessors<F>,
|
|
52
|
+
options?: {
|
|
53
|
+
flowCountsMapReduce?: FlowCountsMapReduce<F>;
|
|
54
|
+
},
|
|
55
|
+
) => (F | AggregateFlow)[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build ClusterIndex from the given cluster hierarchy
|
|
60
|
+
*/
|
|
61
|
+
export function buildIndex<F>(clusterLevels: ClusterLevels): ClusterIndex<F> {
|
|
62
|
+
const nodesByZoom = new Map<number, ClusterNode[]>();
|
|
63
|
+
const clustersById = new Map<string | number, Cluster>();
|
|
64
|
+
const minZoomByLocationId = new Map<string | number, number>();
|
|
65
|
+
for (const {zoom, nodes} of clusterLevels) {
|
|
66
|
+
nodesByZoom.set(zoom, nodes);
|
|
67
|
+
for (const node of nodes) {
|
|
68
|
+
if (isCluster(node)) {
|
|
69
|
+
clustersById.set(node.id, node);
|
|
70
|
+
} else {
|
|
71
|
+
const {id} = node;
|
|
72
|
+
const mz = minZoomByLocationId.get(id);
|
|
73
|
+
if (mz == null || mz > zoom) {
|
|
74
|
+
minZoomByLocationId.set(id, zoom);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [minZoom, maxZoom] = extent(clusterLevels, (cl) => cl.zoom);
|
|
81
|
+
if (minZoom == null || maxZoom == null) {
|
|
82
|
+
throw new Error('Could not determine minZoom or maxZoom');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const leavesToClustersByZoom = new Map<
|
|
86
|
+
number,
|
|
87
|
+
Map<string | number, Cluster>
|
|
88
|
+
>();
|
|
89
|
+
|
|
90
|
+
for (const cluster of clustersById.values()) {
|
|
91
|
+
const {zoom} = cluster;
|
|
92
|
+
let leavesToClusters = leavesToClustersByZoom.get(zoom);
|
|
93
|
+
if (!leavesToClusters) {
|
|
94
|
+
leavesToClusters = new Map<string, Cluster>();
|
|
95
|
+
leavesToClustersByZoom.set(zoom, leavesToClusters);
|
|
96
|
+
}
|
|
97
|
+
visitClusterLeaves(cluster, (leafId) => {
|
|
98
|
+
leavesToClusters?.set(leafId, cluster);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function visitClusterLeaves(cluster: Cluster, visit: (id: string) => void) {
|
|
103
|
+
for (const childId of cluster.children) {
|
|
104
|
+
const child = clustersById.get(childId);
|
|
105
|
+
if (child) {
|
|
106
|
+
visitClusterLeaves(child, visit);
|
|
107
|
+
} else {
|
|
108
|
+
visit(childId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const expandCluster = (cluster: Cluster, targetZoom: number = maxZoom) => {
|
|
114
|
+
const ids: string[] = [];
|
|
115
|
+
const visit = (c: Cluster, expandedIds: (string | number)[]) => {
|
|
116
|
+
if (targetZoom > c.zoom) {
|
|
117
|
+
for (const childId of c.children) {
|
|
118
|
+
const child = clustersById.get(childId);
|
|
119
|
+
if (child) {
|
|
120
|
+
visit(child, expandedIds);
|
|
121
|
+
} else {
|
|
122
|
+
expandedIds.push(childId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
expandedIds.push(c.id);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
visit(cluster, ids);
|
|
130
|
+
return ids;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function findClusterFor(locationId: string | number, zoom: number) {
|
|
134
|
+
const leavesToClusters = leavesToClustersByZoom.get(zoom);
|
|
135
|
+
if (!leavesToClusters) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const cluster = leavesToClusters.get(locationId);
|
|
139
|
+
return cluster ? cluster.id : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const availableZoomLevels = clusterLevels
|
|
143
|
+
.map((cl) => +cl.zoom)
|
|
144
|
+
.sort((a, b) => ascending(a, b));
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
availableZoomLevels,
|
|
148
|
+
|
|
149
|
+
getClusterNodesFor: (zoom) => {
|
|
150
|
+
if (zoom === undefined) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return nodesByZoom.get(zoom);
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
getClusterById: (clusterId) => clustersById.get(clusterId),
|
|
157
|
+
|
|
158
|
+
getMinZoomForLocation: (locationId) =>
|
|
159
|
+
minZoomByLocationId.get(locationId) || minZoom,
|
|
160
|
+
|
|
161
|
+
expandCluster,
|
|
162
|
+
|
|
163
|
+
findClusterFor,
|
|
164
|
+
|
|
165
|
+
aggregateFlows: (
|
|
166
|
+
flows,
|
|
167
|
+
zoom,
|
|
168
|
+
{getFlowOriginId, getFlowDestId, getFlowMagnitude},
|
|
169
|
+
options = {},
|
|
170
|
+
) => {
|
|
171
|
+
if (zoom > maxZoom) {
|
|
172
|
+
return flows;
|
|
173
|
+
}
|
|
174
|
+
const result: (F | AggregateFlow)[] = [];
|
|
175
|
+
const aggFlowsByKey = new Map<string, AggregateFlow>();
|
|
176
|
+
const makeKey = (origin: string | number, dest: string | number) =>
|
|
177
|
+
`${origin}:${dest}`;
|
|
178
|
+
const {
|
|
179
|
+
flowCountsMapReduce = {
|
|
180
|
+
map: getFlowMagnitude,
|
|
181
|
+
reduce: (acc: any, count: number) => (acc || 0) + count,
|
|
182
|
+
},
|
|
183
|
+
} = options;
|
|
184
|
+
for (const flow of flows) {
|
|
185
|
+
const origin = getFlowOriginId(flow);
|
|
186
|
+
const dest = getFlowDestId(flow);
|
|
187
|
+
const originCluster = findClusterFor(origin, zoom) || origin;
|
|
188
|
+
const destCluster = findClusterFor(dest, zoom) || dest;
|
|
189
|
+
const key = makeKey(originCluster, destCluster);
|
|
190
|
+
if (originCluster === origin && destCluster === dest) {
|
|
191
|
+
result.push(flow);
|
|
192
|
+
} else {
|
|
193
|
+
let aggregateFlow = aggFlowsByKey.get(key);
|
|
194
|
+
if (!aggregateFlow) {
|
|
195
|
+
aggregateFlow = {
|
|
196
|
+
origin: originCluster,
|
|
197
|
+
dest: destCluster,
|
|
198
|
+
count: flowCountsMapReduce.map(flow),
|
|
199
|
+
aggregate: true,
|
|
200
|
+
};
|
|
201
|
+
result.push(aggregateFlow);
|
|
202
|
+
aggFlowsByKey.set(key, aggregateFlow);
|
|
203
|
+
} else {
|
|
204
|
+
aggregateFlow.count = flowCountsMapReduce.reduce(
|
|
205
|
+
aggregateFlow.count,
|
|
206
|
+
flowCountsMapReduce.map(flow),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function makeLocationWeightGetter<F>(
|
|
217
|
+
flows: F[],
|
|
218
|
+
{getFlowOriginId, getFlowDestId, getFlowMagnitude}: FlowAccessors<F>,
|
|
219
|
+
): LocationWeightGetter {
|
|
220
|
+
const locationTotals = {
|
|
221
|
+
incoming: new Map<string | number, number>(),
|
|
222
|
+
outgoing: new Map<string | number, number>(),
|
|
223
|
+
};
|
|
224
|
+
for (const flow of flows) {
|
|
225
|
+
const origin = getFlowOriginId(flow);
|
|
226
|
+
const dest = getFlowDestId(flow);
|
|
227
|
+
const count = getFlowMagnitude(flow);
|
|
228
|
+
locationTotals.incoming.set(
|
|
229
|
+
dest,
|
|
230
|
+
(locationTotals.incoming.get(dest) || 0) + count,
|
|
231
|
+
);
|
|
232
|
+
locationTotals.outgoing.set(
|
|
233
|
+
origin,
|
|
234
|
+
(locationTotals.outgoing.get(origin) || 0) + count,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return (id: string | number) =>
|
|
238
|
+
Math.max(
|
|
239
|
+
Math.abs(locationTotals.incoming.get(id) || 0),
|
|
240
|
+
Math.abs(locationTotals.outgoing.get(id) || 0),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @param availableZoomLevels Must be sorted in ascending order
|
|
246
|
+
* @param targetZoom
|
|
247
|
+
*/
|
|
248
|
+
export function findAppropriateZoomLevel(
|
|
249
|
+
availableZoomLevels: number[],
|
|
250
|
+
targetZoom: number,
|
|
251
|
+
) {
|
|
252
|
+
if (!availableZoomLevels.length) {
|
|
253
|
+
throw new Error('No available zoom levels');
|
|
254
|
+
}
|
|
255
|
+
return availableZoomLevels[
|
|
256
|
+
Math.min(
|
|
257
|
+
bisectLeft(availableZoomLevels, Math.floor(targetZoom)),
|
|
258
|
+
availableZoomLevels.length - 1,
|
|
259
|
+
)
|
|
260
|
+
];
|
|
261
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) Flowmap.gl contributors
|
|
3
|
+
* Copyright (c) 2018-2020 Teralytics
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {min, rollup} from 'd3-array';
|
|
8
|
+
import KDBush from 'kdbush';
|
|
9
|
+
import {LocationWeightGetter} from './ClusterIndex';
|
|
10
|
+
import {Cluster, ClusterLevel, ClusterNode, LocationAccessors} from '../types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The code in this file is a based on https://github.com/mapbox/supercluster
|
|
14
|
+
*
|
|
15
|
+
* ISC License
|
|
16
|
+
*
|
|
17
|
+
* Copyright (c) 2016, Mapbox
|
|
18
|
+
*
|
|
19
|
+
* Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
20
|
+
* with or without fee is hereby granted, provided that the above copyright notice
|
|
21
|
+
* and this permission notice appear in all copies.
|
|
22
|
+
*
|
|
23
|
+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
24
|
+
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
25
|
+
* FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
26
|
+
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
|
27
|
+
* OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
|
28
|
+
* TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
|
29
|
+
* THIS SOFTWARE.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface Options {
|
|
33
|
+
minZoom: number; // min zoom to generate clusters on
|
|
34
|
+
maxZoom: number; // max zoom level to cluster the points on
|
|
35
|
+
radius: number; // cluster radius in pixels
|
|
36
|
+
extent: number; // tile extent (radius is calculated relative to it)
|
|
37
|
+
nodeSize: number; // size of the KD-tree leaf node, affects performance
|
|
38
|
+
makeClusterName: (id: number, numPoints: number) => string | undefined;
|
|
39
|
+
makeClusterId: (id: number) => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const defaultOptions: Options = {
|
|
43
|
+
minZoom: 0,
|
|
44
|
+
maxZoom: 16,
|
|
45
|
+
radius: 40,
|
|
46
|
+
extent: 512,
|
|
47
|
+
nodeSize: 64,
|
|
48
|
+
makeClusterName: (id: number, numPoints: number) => undefined,
|
|
49
|
+
makeClusterId: (id: number) => `{[${id}]}`,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
interface BasePoint {
|
|
53
|
+
x: number; // projected point coordinates
|
|
54
|
+
y: number;
|
|
55
|
+
weight: number;
|
|
56
|
+
zoom: number; // the last zoom the point was processed at
|
|
57
|
+
parentId: number; // parent cluster id
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface LeafPoint<L> extends BasePoint {
|
|
61
|
+
index: number; // index of the source feature in the original input array,
|
|
62
|
+
location: L;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface ClusterPoint extends BasePoint {
|
|
66
|
+
id: number;
|
|
67
|
+
numPoints: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Point<L> = LeafPoint<L> | ClusterPoint;
|
|
71
|
+
|
|
72
|
+
export function isLeafPoint<L>(p: Point<L>): p is LeafPoint<L> {
|
|
73
|
+
const {index} = p as LeafPoint<L>;
|
|
74
|
+
return index != null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function isClusterPoint<L>(p: Point<L>): p is ClusterPoint {
|
|
78
|
+
const {id} = p as ClusterPoint;
|
|
79
|
+
return id != null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type ZoomLevelKDBush = any;
|
|
83
|
+
|
|
84
|
+
export function clusterLocations<L>(
|
|
85
|
+
locations: Iterable<L>,
|
|
86
|
+
locationAccessors: LocationAccessors<L>,
|
|
87
|
+
getLocationWeight: LocationWeightGetter,
|
|
88
|
+
options?: Partial<Options>,
|
|
89
|
+
): ClusterLevel[] {
|
|
90
|
+
const {getLocationLon, getLocationLat, getLocationId} = locationAccessors;
|
|
91
|
+
const opts = {
|
|
92
|
+
...defaultOptions,
|
|
93
|
+
...options,
|
|
94
|
+
};
|
|
95
|
+
const {minZoom, maxZoom, nodeSize, makeClusterName, makeClusterId} = opts;
|
|
96
|
+
|
|
97
|
+
const trees = new Array<ZoomLevelKDBush>(maxZoom + 1);
|
|
98
|
+
|
|
99
|
+
// generate a cluster object for each point and index input points into a KD-tree
|
|
100
|
+
let clusters = new Array<Point<L>>();
|
|
101
|
+
let locationsCount = 0;
|
|
102
|
+
for (const location of locations) {
|
|
103
|
+
const x = getLocationLon(location);
|
|
104
|
+
const y = getLocationLat(location);
|
|
105
|
+
clusters.push({
|
|
106
|
+
x: lngX(x), // projected point coordinates
|
|
107
|
+
y: latY(y),
|
|
108
|
+
weight: getLocationWeight(getLocationId(location)),
|
|
109
|
+
zoom: Infinity, // the last zoom the point was processed at
|
|
110
|
+
index: locationsCount, // index of the source feature in the original input array,
|
|
111
|
+
parentId: -1, // parent cluster id
|
|
112
|
+
location,
|
|
113
|
+
});
|
|
114
|
+
locationsCount++;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const makeBush = (points: Point<L>[]) => {
|
|
118
|
+
const bush = new KDBush(points.length, nodeSize, Float32Array);
|
|
119
|
+
for (let i = 0; i < points.length; i++) {
|
|
120
|
+
bush.add(points[i].x, points[i].y);
|
|
121
|
+
}
|
|
122
|
+
bush.finish();
|
|
123
|
+
bush.points = points;
|
|
124
|
+
return bush;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// cluster points on max zoom, then cluster the results on previous zoom, etc.;
|
|
128
|
+
// results in a cluster hierarchy across zoom levels
|
|
129
|
+
trees[maxZoom + 1] = makeBush(clusters);
|
|
130
|
+
let prevZoom = maxZoom + 1;
|
|
131
|
+
|
|
132
|
+
for (let z = maxZoom; z >= minZoom; z--) {
|
|
133
|
+
// create a new set of clusters for the zoom and index them with a KD-tree
|
|
134
|
+
const _clusters = cluster(clusters, z, trees[prevZoom], opts);
|
|
135
|
+
if (_clusters.length === clusters.length) {
|
|
136
|
+
// same number of clusters => move the higher level clusters up
|
|
137
|
+
// no need to keep the same data on multiple levels
|
|
138
|
+
trees[z] = trees[prevZoom];
|
|
139
|
+
trees[prevZoom] = undefined;
|
|
140
|
+
prevZoom = z;
|
|
141
|
+
clusters = _clusters;
|
|
142
|
+
} else {
|
|
143
|
+
prevZoom = z;
|
|
144
|
+
clusters = _clusters;
|
|
145
|
+
trees[z] = makeBush(clusters);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (trees.length === 0) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const numbersOfClusters: number[] = trees.map((d) => d?.points.length);
|
|
154
|
+
const minClusters = min(numbersOfClusters.filter((d) => d > 0));
|
|
155
|
+
|
|
156
|
+
let maxAvailZoom =
|
|
157
|
+
findIndexOfMax(numbersOfClusters) ?? numbersOfClusters.length - 1;
|
|
158
|
+
|
|
159
|
+
const numUniqueLocations = countUniqueLocations(locations, locationAccessors);
|
|
160
|
+
if (numUniqueLocations < locationsCount) {
|
|
161
|
+
// Duplicate locations would be clustered together at any zoom level which can lead to having too many zooms.
|
|
162
|
+
// To avoid that, we need to find the max zoom level that has less or equal clusters than unique locations
|
|
163
|
+
// and drop all zoom levels beyond that (except the unclustered level).
|
|
164
|
+
const maxClustersZoom = findLastIndex(
|
|
165
|
+
numbersOfClusters,
|
|
166
|
+
(d) => d <= numUniqueLocations,
|
|
167
|
+
);
|
|
168
|
+
if (maxClustersZoom >= 0) {
|
|
169
|
+
// Now, move the unclustered points to the next zoom level to avoid having a gap
|
|
170
|
+
if (maxClustersZoom < maxAvailZoom) {
|
|
171
|
+
trees[maxClustersZoom + 1] = trees[maxAvailZoom];
|
|
172
|
+
trees.splice(maxClustersZoom + 2); // Remove all zoom levels beyond maxClustersZoom
|
|
173
|
+
}
|
|
174
|
+
maxAvailZoom = maxClustersZoom + 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const minAvailZoom = Math.min(
|
|
179
|
+
maxAvailZoom,
|
|
180
|
+
minClusters ? numbersOfClusters.lastIndexOf(minClusters) : maxAvailZoom,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const clusterLevels = new Array<ClusterLevel>();
|
|
184
|
+
prevZoom = NaN;
|
|
185
|
+
for (let zoom = maxAvailZoom; zoom >= minAvailZoom; zoom--) {
|
|
186
|
+
let childrenByParent: Map<number, (string | number)[]> | undefined;
|
|
187
|
+
const tree = trees[zoom];
|
|
188
|
+
if (!tree) continue;
|
|
189
|
+
if (trees[prevZoom] && zoom < maxAvailZoom) {
|
|
190
|
+
childrenByParent = rollup(
|
|
191
|
+
trees[prevZoom].points,
|
|
192
|
+
(points: any[]) =>
|
|
193
|
+
points.map((p: any) =>
|
|
194
|
+
p.id ? makeClusterId(p.id) : getLocationId(p.location),
|
|
195
|
+
),
|
|
196
|
+
(point: any) => point.parentId,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const nodes: ClusterNode[] = [];
|
|
201
|
+
for (const point of tree.points) {
|
|
202
|
+
const {x, y, numPoints, location} = point;
|
|
203
|
+
if (isLeafPoint(point)) {
|
|
204
|
+
nodes.push({
|
|
205
|
+
id: getLocationId(location),
|
|
206
|
+
zoom,
|
|
207
|
+
lat: getLocationLat(location),
|
|
208
|
+
lon: getLocationLon(location),
|
|
209
|
+
});
|
|
210
|
+
} else if (isClusterPoint(point)) {
|
|
211
|
+
const {id} = point;
|
|
212
|
+
const children = childrenByParent && childrenByParent.get(id);
|
|
213
|
+
if (!children) {
|
|
214
|
+
// Might happen if there are multiple locations with same coordinates
|
|
215
|
+
console.warn(`Omitting cluster with no children, point:`, point);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const cluster = {
|
|
219
|
+
id: makeClusterId(id),
|
|
220
|
+
name: makeClusterName(id, numPoints),
|
|
221
|
+
zoom,
|
|
222
|
+
lat: yLat(y),
|
|
223
|
+
lon: xLng(x),
|
|
224
|
+
children: children ?? [],
|
|
225
|
+
} as Cluster;
|
|
226
|
+
nodes.push(cluster);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
clusterLevels.push({
|
|
230
|
+
zoom,
|
|
231
|
+
nodes,
|
|
232
|
+
});
|
|
233
|
+
prevZoom = zoom;
|
|
234
|
+
}
|
|
235
|
+
return clusterLevels;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function createCluster(
|
|
239
|
+
x: number,
|
|
240
|
+
y: number,
|
|
241
|
+
id: number,
|
|
242
|
+
numPoints: number,
|
|
243
|
+
weight: number,
|
|
244
|
+
): ClusterPoint {
|
|
245
|
+
return {
|
|
246
|
+
x, // weighted cluster center
|
|
247
|
+
y,
|
|
248
|
+
zoom: Infinity, // the last zoom the cluster was processed at
|
|
249
|
+
id, // encodes index of the first child of the cluster and its zoom level
|
|
250
|
+
parentId: -1, // parent cluster id
|
|
251
|
+
numPoints,
|
|
252
|
+
weight,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function cluster<L>(
|
|
257
|
+
points: Point<L>[],
|
|
258
|
+
zoom: number,
|
|
259
|
+
tree: ZoomLevelKDBush,
|
|
260
|
+
options: Options,
|
|
261
|
+
) {
|
|
262
|
+
const clusters: Point<L>[] = [];
|
|
263
|
+
const {radius, extent} = options;
|
|
264
|
+
const r = radius / (extent * Math.pow(2, zoom));
|
|
265
|
+
|
|
266
|
+
// loop through each point
|
|
267
|
+
for (let i = 0; i < points.length; i++) {
|
|
268
|
+
const p = points[i];
|
|
269
|
+
// if we've already visited the point at this zoom level, skip it
|
|
270
|
+
if (p.zoom <= zoom) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
p.zoom = zoom;
|
|
274
|
+
|
|
275
|
+
// find all nearby points
|
|
276
|
+
const neighborIds = tree.within(p.x, p.y, r);
|
|
277
|
+
|
|
278
|
+
let weight = p.weight || 1;
|
|
279
|
+
let numPoints = isClusterPoint(p) ? p.numPoints : 1;
|
|
280
|
+
let wx = p.x * weight;
|
|
281
|
+
let wy = p.y * weight;
|
|
282
|
+
|
|
283
|
+
// encode both zoom and point index on which the cluster originated
|
|
284
|
+
const id = (i << 5) + (zoom + 1);
|
|
285
|
+
|
|
286
|
+
for (const neighborId of neighborIds) {
|
|
287
|
+
const b = tree.points[neighborId];
|
|
288
|
+
// filter out neighbors that are already processed
|
|
289
|
+
if (b.zoom <= zoom) {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
b.zoom = zoom; // save the zoom (so it doesn't get processed twice)
|
|
293
|
+
|
|
294
|
+
const weight2 = b.weight || 1;
|
|
295
|
+
const numPoints2 = b.numPoints || 1;
|
|
296
|
+
wx += b.x * weight2; // accumulate coordinates for calculating weighted center
|
|
297
|
+
wy += b.y * weight2;
|
|
298
|
+
|
|
299
|
+
weight += weight2;
|
|
300
|
+
numPoints += numPoints2;
|
|
301
|
+
b.parentId = id;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (numPoints === 1) {
|
|
305
|
+
clusters.push(p);
|
|
306
|
+
} else {
|
|
307
|
+
p.parentId = id;
|
|
308
|
+
clusters.push(
|
|
309
|
+
createCluster(wx / weight, wy / weight, id, numPoints, weight),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return clusters;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// spherical mercator to longitude/latitude
|
|
318
|
+
function xLng(x: number) {
|
|
319
|
+
return (x - 0.5) * 360;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function yLat(y: number) {
|
|
323
|
+
const y2 = ((180 - y * 360) * Math.PI) / 180;
|
|
324
|
+
return (360 * Math.atan(Math.exp(y2))) / Math.PI - 90;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// longitude/latitude to spherical mercator in [0..1] range
|
|
328
|
+
function lngX(lng: number) {
|
|
329
|
+
return lng / 360 + 0.5;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function latY(lat: number) {
|
|
333
|
+
const sin = Math.sin((lat * Math.PI) / 180);
|
|
334
|
+
const y = 0.5 - (0.25 * Math.log((1 + sin) / (1 - sin))) / Math.PI;
|
|
335
|
+
return y < 0 ? 0 : y > 1 ? 1 : y;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function getX<L>(p: Point<L>) {
|
|
339
|
+
return p.x;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getY<L>(p: Point<L>) {
|
|
343
|
+
return p.y;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function countUniqueLocations<L>(
|
|
347
|
+
locations: Iterable<L>,
|
|
348
|
+
locationAccessors: LocationAccessors<L>,
|
|
349
|
+
) {
|
|
350
|
+
const {getLocationLon, getLocationLat} = locationAccessors;
|
|
351
|
+
const countByLatLon = new Map<string, number>();
|
|
352
|
+
let uniqueCnt = 0;
|
|
353
|
+
for (const loc of locations) {
|
|
354
|
+
const lon = getLocationLon(loc);
|
|
355
|
+
const lat = getLocationLat(loc);
|
|
356
|
+
const key = `${lon},${lat}`;
|
|
357
|
+
const prev = countByLatLon.get(key);
|
|
358
|
+
if (!prev) {
|
|
359
|
+
uniqueCnt++;
|
|
360
|
+
}
|
|
361
|
+
countByLatLon.set(key, prev ? prev + 1 : 1);
|
|
362
|
+
}
|
|
363
|
+
return uniqueCnt;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function findIndexOfMax(arr: (number | undefined)[]): number | undefined {
|
|
367
|
+
let max = -Infinity;
|
|
368
|
+
let maxIndex: number | undefined = undefined;
|
|
369
|
+
|
|
370
|
+
for (let i = 0; i < arr.length; i++) {
|
|
371
|
+
const value = arr[i];
|
|
372
|
+
|
|
373
|
+
if (typeof value === 'number') {
|
|
374
|
+
if (value > max) {
|
|
375
|
+
max = value;
|
|
376
|
+
maxIndex = i;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return maxIndex;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function findLastIndex<T>(
|
|
385
|
+
arr: T[],
|
|
386
|
+
predicate: (value: T, index: number, array: T[]) => boolean,
|
|
387
|
+
): number {
|
|
388
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
389
|
+
if (predicate(arr[i], i, arr)) {
|
|
390
|
+
return i;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return -1;
|
|
394
|
+
}
|