1 | import * as React from 'react';
|
2 | import { LngLatBounds, Map } from 'mapbox-gl';
|
3 | import { Props as MarkerProps } from './marker';
|
4 | import Supercluster from 'supercluster';
|
5 | import * as GeoJSON from 'geojson';
|
6 | import bbox from '@turf/bbox';
|
7 | import { polygon, featureCollection } from '@turf/helpers';
|
8 | import { withMap } from './context';
|
9 |
|
10 | export interface Props {
|
11 | ClusterMarkerFactory(
|
12 | coordinates: GeoJSON.Position,
|
13 | pointCount: number,
|
14 | getLeaves: (
|
15 | limit?: number,
|
16 | offset?: number
|
17 | ) => Array<React.ReactElement<MarkerProps> | undefined>
|
18 | ): React.ReactElement<MarkerProps>;
|
19 | radius?: number;
|
20 | maxZoom?: number;
|
21 | minZoom?: number;
|
22 | extent?: number;
|
23 | nodeSize?: number;
|
24 | log?: boolean;
|
25 | zoomOnClick?: boolean;
|
26 | zoomOnClickPadding?: number;
|
27 | children?: Array<React.ReactElement<MarkerProps>>;
|
28 | style?: React.CSSProperties;
|
29 | className?: string;
|
30 | tabIndex?: number;
|
31 | map: Map;
|
32 | }
|
33 |
|
34 | export interface State {
|
35 | superC: Supercluster;
|
36 | clusterPoints: Array<GeoJSON.Feature<GeoJSON.Point>>;
|
37 | }
|
38 |
|
39 | export class Cluster extends React.Component<Props, State> {
|
40 | public static defaultProps = {
|
41 | radius: 60,
|
42 | minZoom: 0,
|
43 | maxZoom: 16,
|
44 | extent: 512,
|
45 | nodeSize: 64,
|
46 | log: false,
|
47 | zoomOnClick: false,
|
48 | zoomOnClickPadding: 20
|
49 | };
|
50 |
|
51 | public state: State = {
|
52 | superC: new Supercluster({
|
53 | radius: this.props.radius,
|
54 | maxZoom: this.props.maxZoom,
|
55 | minZoom: this.props.minZoom,
|
56 | extent: this.props.extent,
|
57 | nodeSize: this.props.nodeSize,
|
58 | log: this.props.log
|
59 | }),
|
60 | clusterPoints: []
|
61 | };
|
62 |
|
63 | private featureClusterMap = new WeakMap<
|
64 | GeoJSON.Feature,
|
65 | React.ReactElement<MarkerProps>
|
66 | >();
|
67 |
|
68 | public componentDidMount() {
|
69 | const { children, map } = this.props;
|
70 |
|
71 | if (children) {
|
72 | this.childrenChange(children as Array<React.ReactElement<MarkerProps>>);
|
73 | }
|
74 |
|
75 | map.on('move', this.mapChange);
|
76 | map.on('zoom', this.mapChange);
|
77 | this.mapChange();
|
78 | }
|
79 |
|
80 | public componentWillUnmount() {
|
81 | const { map } = this.props;
|
82 |
|
83 | map.off('move', this.mapChange);
|
84 | map.off('zoom', this.mapChange);
|
85 | }
|
86 |
|
87 | public componentDidUpdate(prevProps: Props) {
|
88 | const { children } = prevProps;
|
89 |
|
90 | if (children !== this.props.children && this.props.children) {
|
91 | this.childrenChange(this.props.children);
|
92 | this.mapChange(true);
|
93 | }
|
94 | }
|
95 |
|
96 | private childrenChange = (
|
97 | newChildren: Array<React.ReactElement<MarkerProps>>
|
98 | ) => {
|
99 | const { superC } = this.state;
|
100 | this.featureClusterMap = new WeakMap<
|
101 | GeoJSON.Feature,
|
102 | React.ReactElement<MarkerProps>
|
103 | >();
|
104 | const features = this.childrenToFeatures(newChildren);
|
105 | superC.load(features);
|
106 | };
|
107 |
|
108 | private mapChange = (forceSetState: boolean = false) => {
|
109 | const { map } = this.props;
|
110 | const { superC, clusterPoints } = this.state;
|
111 |
|
112 | const zoom = map.getZoom();
|
113 | const canvas = map.getCanvas();
|
114 | const w = canvas.width;
|
115 | const h = canvas.height;
|
116 | const upLeft = map.unproject([0, 0]).toArray();
|
117 | const upRight = map.unproject([w, 0]).toArray();
|
118 | const downRight = map.unproject([w, h]).toArray();
|
119 | const downLeft = map.unproject([0, h]).toArray();
|
120 | const newPoints = superC.getClusters(
|
121 | bbox(polygon([[upLeft, upRight, downRight, downLeft, upLeft]])),
|
122 | Math.round(zoom)
|
123 | );
|
124 | if (newPoints.length !== clusterPoints.length || forceSetState) {
|
125 | this.setState({ clusterPoints: newPoints });
|
126 | }
|
127 | };
|
128 |
|
129 | private feature(coordinates: GeoJSON.Position) {
|
130 | return {
|
131 | type: 'Feature' as 'Feature',
|
132 | geometry: {
|
133 | type: 'Point' as 'Point',
|
134 | coordinates
|
135 | },
|
136 | properties: {}
|
137 | };
|
138 | }
|
139 |
|
140 | private childrenToFeatures = (
|
141 | children: Array<React.ReactElement<MarkerProps>>
|
142 | ) =>
|
143 | children.map(child => {
|
144 | const feature = this.feature(child && child.props.coordinates);
|
145 | this.featureClusterMap.set(feature, child);
|
146 | return feature;
|
147 | });
|
148 |
|
149 | private getLeaves = (
|
150 | feature: GeoJSON.Feature,
|
151 | limit?: number,
|
152 | offset?: number
|
153 | ) => {
|
154 | const { superC } = this.state;
|
155 | return superC
|
156 | .getLeaves(
|
157 | feature.properties && feature.properties.cluster_id,
|
158 | limit || Infinity,
|
159 | offset
|
160 | )
|
161 | .map((leave: GeoJSON.Feature) => this.featureClusterMap.get(leave));
|
162 | };
|
163 |
|
164 | public zoomToClusterBounds = (event: React.MouseEvent<HTMLElement>) => {
|
165 | const markers = Array.prototype.slice.call(event.currentTarget.children);
|
166 | const marker = this.findMarkerElement(
|
167 | event.currentTarget,
|
168 | event.target as HTMLElement
|
169 | );
|
170 | const index = markers.indexOf(marker);
|
171 | const cluster = this.state.clusterPoints[index] as GeoJSON.Feature;
|
172 | if (!cluster.properties || !cluster.properties.cluster_id) {
|
173 | return;
|
174 | }
|
175 | const children = this.state.superC.getLeaves(
|
176 | cluster.properties && cluster.properties.cluster_id,
|
177 | Infinity
|
178 | );
|
179 | const childrenBbox = bbox(featureCollection(children));
|
180 |
|
181 |
|
182 | this.props.map.fitBounds(LngLatBounds.convert(childrenBbox as any), {
|
183 | padding: this.props.zoomOnClickPadding!
|
184 | });
|
185 | };
|
186 |
|
187 | private findMarkerElement(
|
188 | target: HTMLElement,
|
189 | element: HTMLElement
|
190 | ): HTMLElement {
|
191 | if (element.parentElement === target) {
|
192 | return element;
|
193 | }
|
194 | return this.findMarkerElement(target, element.parentElement!);
|
195 | }
|
196 |
|
197 | public render() {
|
198 | const { ClusterMarkerFactory, style, className, tabIndex } = this.props;
|
199 | const { clusterPoints } = this.state;
|
200 |
|
201 | return (
|
202 | <div
|
203 | style={style}
|
204 | className={className}
|
205 | tabIndex={tabIndex}
|
206 | onClick={this.props.zoomOnClick ? this.zoomToClusterBounds : undefined}
|
207 | >
|
208 | {clusterPoints.map((feature: GeoJSON.Feature<GeoJSON.Point>) => {
|
209 | if (feature.properties && feature.properties.cluster) {
|
210 | return ClusterMarkerFactory(
|
211 | feature.geometry.coordinates,
|
212 | feature.properties.point_count,
|
213 | this.getLeaves.bind(this, feature)
|
214 | );
|
215 | }
|
216 | return this.featureClusterMap.get(feature);
|
217 | })}
|
218 | </div>
|
219 | );
|
220 | }
|
221 | }
|
222 |
|
223 | export default withMap(Cluster);
|