51 | );
52 | }
53 |
54 | }
55 |
56 | const map = (
57 |
66 | );
67 |
68 | render(map, document.getElementById('app'));
69 | ```
70 |
71 | ## API
72 |
73 | The `ClusterLayer` component takes the following props:
74 |
75 | - `markers`: an array of objects that expose the properties defined in the `Marker` type
76 | - `clusterComponent`: (required) the React component to be rendered for each marker and cluster, this component will receive the following props
77 | - `cluster`: a `Cluster` object, as defined by the Cluster Flow type
78 | - `style`: a style object for positioning
79 | - `map`: the Leaflet map object from the `react-leaflet` `MapLayer`
80 | - `...propsForClusters`: the component will also receive the properties of `propsForClusters` as props
81 | - `propsForClusters`: props to pass on to marker and cluster components
82 | - `gridSize`: optional prop to control how bounds of clusters expand while being generated (default: 60)
83 | - `minClusterSize`: optional prop to enforce a minimum cluster size (default: 2)
84 |
85 | ## Example
86 |
87 | To try the example:
88 |
89 | 1. Clone this repository
90 | 2. run `npm install` in the root of your cloned repository
91 | 3. run `npm run example`
92 | 4. Visit [localhost:8000](http://localhost:8000)
93 |
94 | ## Contributing
95 |
96 | See [CONTRIBUTING.md](https://www.github.com/OpenGov/react-leaflet-cluster-layer/blob/master/CONTRIBUTING.md)
97 |
98 | ## License
99 |
100 | `react-leaflet-cluster-layer` is MIT licensed.
101 |
102 | See [LICENSE.md](https://www.github.com/OpenGov/react-leaflet-cluster-layer/blob/master/LICENSE.md) for details.
103 |
--------------------------------------------------------------------------------
/src/__tests__/ClusterLayer.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { shallow, mount, render } from 'enzyme';
3 | import { Map } from 'react-leaflet';
4 | import ClusterLayer from '../ClusterLayer.js';
5 |
6 | jest.unmock('../ClusterLayer.js');
7 |
8 | const L = jest.genMockFromModule('leaflet');
9 |
10 | class ClusterComponent extends React.Component {
11 | render() {
12 | return (
13 |
Cluster component
14 | );
15 | }
16 | }
17 |
18 | describe('ClusterLayer', () => {
19 |
20 | /*
21 | Here we declare mocks or fixtures of
22 | all the data structures that the component uses
23 | */
24 | const center = { lng: -122.673447, lat: 45.522558 };
25 |
26 | const mockBounds = {
27 | contains: jest.fn(() => true),
28 | extend: jest.fn(),
29 | getNorthEast: jest.fn(() => ({ lng: -122, lat: 46 })),
30 | getSouthWest: jest.fn(() => ({ lng: -123, lat: 45 })),
31 | };
32 |
33 | const mockPanes = { overlayPane: { appendChild: jest.fn() } };
34 |
35 | const mockMap = {
36 | layerPointToLatLng: jest.fn(() => ({ lng: -122.6, lat: 45.522 })),
37 | latLngToLayerPoint: jest.fn(() => ({ x: 100, y: 100 })),
38 | on: jest.fn(),
39 | getBounds: jest.fn(() => mockBounds),
40 | getPanes: jest.fn(() => mockPanes),
41 | invalidateSize: jest.fn()
42 | };
43 |
44 | const mockMarkers = [
45 | {
46 | position: { lng: -122.673447, lat: 45.5225581 },
47 | text: 'Voodoo Doughnut',
48 | },
49 | {
50 | position: { lng: -122.6781446, lat: 45.5225512 },
51 | text: 'Bailey\'s Taproom',
52 | },
53 | {
54 | position: { lng: -122.67535700000002, lat: 45.5192743 },
55 | text: 'Barista'
56 | }
57 | ];
58 |
59 | it('should render', () => {
60 | const layer = render(
61 |
64 | );
65 | expect(layer).toBeTruthy();
66 | });
67 |
68 | it('should render a single child given one marker', () => {
69 | const layer = mount(
70 |
74 | );
75 | expect(layer.find('.cluster-component').length).toEqual(1);
76 | });
77 |
78 | it('should render one child given three markers with the same position', () => {
79 | const layer = mount(
80 |
84 | );
85 | expect(layer.find('.cluster-component').length).toEqual(1);
86 | });
87 |
88 | it('should render three child given three markers with the different positions', () => {
89 | const layer = mount(
90 |
94 | );
95 | expect(layer.find('.cluster-component').length).toEqual(3);
96 | });
97 |
98 | it('should pass the `propsForClusters` prop to rendered ', () => {
99 | const mockProps = {
100 | theAnswer: 42,
101 | numCoffees: 3
102 | };
103 |
104 | const layer = mount(
105 |
110 | );
111 | const componentProps = layer.find(ClusterComponent).at(0).props();
112 | expect(componentProps).toBeTruthy();
113 | expect(componentProps.theAnswer).toEqual(mockProps.theAnswer);
114 | expect(componentProps.numCoffees).toEqual(mockProps.numCoffees);
115 | });
116 | });
117 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Things you can do to contribute include:
4 |
5 | 1. Report a bug by [opening an issue](https://github.com/OpenGov/react-leaflet-cluster-layer/issues/new)
6 | 2. Suggest a change by [opening an issue](https://www.github.com/OpenGov/react-leaflet-cluster-layer/issues/new)
7 | 3. Fork the repository and fix [an open issue](https://github.com/OpenGov/react-leaflet-cluster-layer/issues)
8 |
9 | ### Technology
10 |
11 | `react-leaflet-cluster-layer` is a custom layer for the `react-leaflet` package. This package is a convenient wrapper around Leaflet, a mapping library, for React. The source code is written with ES6/ES2015 syntax as well as [FlowType](http://flowtype.org) type annotations.
12 |
13 | ### Install dependencies
14 |
15 | 1. Install Node via [nodejs.org](http://nodejs.org)
16 | 2. After cloning the repository, run `npm install` in the root of the project
17 |
18 | This project is developed with Node version 4.3.0 and NPM 3.3.10.
19 |
20 | ### Contributing via Github
21 |
22 | The entire project can be found [on Github](https://github.com/OpenGov/react-leaflet-cluster-layer). We use the [fork and pull model](https://help.github.com/articles/using-pull-requests/) to process contributions.
23 |
24 | #### Fork the Repository
25 |
26 | Before contributing, you'll need to fork the repository:
27 |
28 | 1. Fork the repository so you have your own copy (`your-username/react-leaflet-cluster-layer`)
29 | 2. Clone the repo locally with `git clone https://github.com/your-username/react-leaflet-cluster-layer`
30 | 3. Move into the cloned repo: `cd react-leaflet-cluster-layer`
31 | 4. Install the project's dependencies: `npm install`
32 |
33 | You should also add `OpenGov/react-leaflet-cluster-layer` as a remote at this point. We generally call this remote branch 'upstream':
34 |
35 | ```
36 | git remote add upstream https://github.com/OpenGov/react-leaflet-cluster-layer
37 | ```
38 |
39 | #### Development
40 |
41 | You can work with a live, hot-reloading example of the component by running:
42 |
43 | ```bash
44 | npm run example
45 | ```
46 |
47 | And then visiting [localhost:8000](http://localhost:8000).
48 |
49 | As you make changes, please describe them in `CHANGELOG.md`.
50 |
51 | #### Submitting a Pull Request
52 |
53 | Before submitting a Pull Request please ensure you have completed the following tasks:
54 |
55 | 1. Describe your changes in `CHANGELOG.md`
56 | 2. Make sure your copy is up to date: `git pull upstream master`
57 | 3. Ensure that all tests pass, tests for the component can be found in `lib/__tests__/ClusterLayer.test.js`, you can run these tests with `npm test`
58 | 3. Run `npm run compile`, to compile your changes to the exported `/lib` code.
59 | 4. Bump the version in `package.json` as appropriate, see `Versioning` in the section below.
60 | 4. Commit your changes
61 | 5. Push your changes to your fork: `your-username/react-leaflet-cluster-layer`
62 | 6. Open a pull request from your fork to the `upstream` fork (`OpenGov/react-leaflet-cluster-layer`)
63 |
64 | ## Versioning
65 |
66 | This project follows Semantic Versioning.This means that version numbers are basically formatted like `MAJOR.MINOR.PATCH`.
67 |
68 | #### Major
69 |
70 | Breaking changes are signified with a new **first** number. For example, moving from 1.0.0 to 2.0.0 implies breaking changes.
71 |
72 | #### Minor
73 |
74 | New components, new helper classes, or substantial visual changes to existing components and patterns are *minor releases*. These are signified by the second number changing. So from 1.1.2 to 1.2.0 there are minor changes.
75 |
76 | #### Patches
77 |
78 | The final number signifies patches such as fixing a pattern or component in a certain browser, or fixing an existing bug. Small changes to the documentation site and the tooling around the Calcite-Web library are also considered patches.
79 |
80 | ## Developer's Certificate of Origin 1.1
81 |
82 | By making a contribution to this project, I certify that:
83 |
84 | * (a) The contribution was created in whole or in part by me and I
85 | have the right to submit it under the open source license
86 | indicated in the file; or
87 |
88 | * (b) The contribution is based upon previous work that, to the best
89 | of my knowledge, is covered under an appropriate open source
90 | license and I have the right under that license to submit that
91 | work with modifications, whether created in whole or in part
92 | by me, under the same open source license (unless I am
93 | permitted to submit under a different license), as indicated
94 | in the file; or
95 |
96 | * (c) The contribution was provided directly to me by some other
97 | person who certified (a), (b) or (c) and I have not modified
98 | it.
99 |
100 | * (d) I understand and agree that this project and the contribution
101 | are public and that a record of the contribution (including all
102 | personal information I submit with it, including my sign-off) is
103 | maintained indefinitely and may be redistributed consistent with
104 | this project or the open source license(s) involved.
105 |
--------------------------------------------------------------------------------
/src/ClusterLayer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import L from 'leaflet';
5 | import { MapLayer } from 'react-leaflet';
6 |
7 | export type LngLat = {
8 | lng: number;
9 | lat: number;
10 | }
11 |
12 | export type Marker = {
13 | position: LngLat;
14 | isAdded: boolean;
15 | }
16 |
17 | export type Point = {
18 | x: number;
19 | y: number;
20 | }
21 |
22 | export type Bounds = {
23 | contains: (latLng: LngLat) => boolean;
24 | extend: (latLng: LngLat) => void;
25 | getNorthEast: () => LngLat;
26 | getSouthWest: () => LngLat;
27 | }
28 |
29 | export type Map = {
30 | layerPointToLatLng: (lngLat: Point) => LngLat;
31 | latLngToLayerPoint: (lngLat: LngLat) => Point;
32 | on: (event: string, handler: () => void) => void;
33 | getBounds: () => Bounds;
34 | getPanes: () => Panes;
35 | invalidateSize: () => void;
36 | }
37 |
38 | export type Panes = {
39 | overlayPane: Pane;
40 | }
41 |
42 | export type Pane = {
43 | appendChild: (element: Object) => void;
44 | }
45 |
46 | export type Cluster = {
47 | center: LngLat;
48 | markers: Array;
49 | bounds: Bounds;
50 | }
51 |
52 | // Taken from http://stackoverflow.com/questions/1538681/how-to-call-fromlatlngtodivpixel-in-google-maps-api-v3/12026134#12026134
53 | // and modified to use Leaflet API
54 | function getExtendedBounds(map: Map, bounds: Bounds, gridSize: number): Bounds {
55 | // Turn the bounds into latlng.
56 | const northEastLat = bounds && bounds.getNorthEast() && bounds.getNorthEast().lat;
57 | const northEastLng = bounds && bounds.getNorthEast() && bounds.getNorthEast().lng;
58 | const southWestLat = bounds && bounds.getSouthWest() && bounds.getSouthWest().lat;
59 | const southWestLng = bounds && bounds.getSouthWest() && bounds.getSouthWest().lng;
60 |
61 | const tr = L.latLng(northEastLat, northEastLng);
62 | const bl = L.latLng(southWestLat, southWestLng);
63 |
64 | // Convert the points to pixels and the extend out by the grid size.
65 | const trPix = map.latLngToLayerPoint(tr);
66 | trPix.x += gridSize;
67 | trPix.y -= gridSize;
68 |
69 | const blPix = map.latLngToLayerPoint(bl);
70 | blPix.x -= gridSize;
71 | blPix.y += gridSize;
72 |
73 | // Convert the pixel points back to LatLng
74 | const ne = map.layerPointToLatLng(trPix);
75 | const sw = map.layerPointToLatLng(blPix);
76 |
77 | // Extend the bounds to contain the new bounds.
78 | bounds.extend(ne);
79 | bounds.extend(sw);
80 |
81 | return bounds;
82 | }
83 |
84 | function distanceBetweenPoints(p1: LngLat, p2: LngLat): number {
85 | if (!p1 || !p2) {
86 | return 0;
87 | }
88 |
89 | const R = 6371; // Radius of the Earth in km
90 |
91 | const degreesToRadians = degree => (degree * Math.PI / 180);
92 | const sinDouble = degree => Math.pow(Math.sin(degree / 2), 2);
93 | const cosSquared = (point1, point2) => {
94 | return Math.cos(degreesToRadians(point1.lat)) * Math.cos(degreesToRadians(point2.lat));
95 | };
96 |
97 | const dLat = degreesToRadians(p2.lat - p1.lat);
98 | const dLon = degreesToRadians(p2.lng - p1.lng);
99 | const a = sinDouble(dLat) + cosSquared(p1, p2) * sinDouble(dLon);
100 | const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
101 | const d = R * c;
102 | return d;
103 | }
104 |
105 | export default class ClusterLayer extends MapLayer {
106 | static propTypes = {
107 | markers: React.PropTypes.array,
108 | clusterComponent: React.PropTypes.func.isRequired,
109 | propsForClusters: React.PropTypes.object,
110 | gridSize: React.PropTypes.number,
111 | minClusterSize: React.PropTypes.number
112 | };
113 |
114 | state: Object = {
115 | clusters: []
116 | };
117 |
118 | componentDidMount(): void {
119 | this.leafletElement = ReactDOM.findDOMNode(this.refs.container);
120 | this.props.map.getPanes().overlayPane.appendChild(this.leafletElement);
121 | this.setClustersWith(this.props.markers);
122 | this.attachEvents();
123 | }
124 |
125 | componentWillReceiveProps(nextProps: Object): void {
126 | if (!this.props || nextProps.markers !== this.props.markers) {
127 | this.setClustersWith(nextProps.markers);
128 | }
129 | }
130 |
131 | componentWillUnmount(): void {
132 | this.props.map.getPanes().overlayPane.removeChild(this.leafletElement);
133 | }
134 |
135 | componentDidUpdate(): void {
136 | this.props.map.invalidateSize();
137 | this.updatePosition();
138 | }
139 |
140 | shouldComponentUpdate(): boolean {
141 | return true;
142 | }
143 |
144 | setClustersWith(markers: Array): void {
145 | this.setState({
146 | clusters: this.createClustersFor(markers)
147 | });
148 | }
149 |
150 | recalculate(): void {
151 | this.setClustersWith(this.props.markers);
152 | this.updatePosition();
153 | }
154 |
155 | attachEvents(): void {
156 | const map: Map = this.props.map;
157 |
158 | map.on('viewreset', () => this.recalculate());
159 | map.on('moveend', () => this.recalculate());
160 | }
161 |
162 | updatePosition(): void {
163 | this.state.clusters.forEach((cluster: Cluster, i) => {
164 | const clusterElement = ReactDOM.findDOMNode(
165 | this.refs[this.getClusterRefName(i)]
166 | );
167 |
168 | L.DomUtil.setPosition(
169 | clusterElement,
170 | this.props.map.latLngToLayerPoint(cluster.center)
171 | );
172 | });
173 | }
174 |
175 | render(): React.Element {
176 | return (
177 |