├── .gitignore ├── LICENSE ├── README.md ├── babelPresets.js ├── demo └── demo.gif ├── dist ├── common │ ├── constants │ │ ├── ClusterOptions.js │ │ └── GeoJSONTypes.js │ ├── hoc │ │ ├── connectWithSpiderifierPoint.js │ │ ├── detectLocationHasOverlappedPoints.js │ │ ├── doZoomingOnClick.js │ │ ├── index.js │ │ └── spiderifier.css │ └── utils │ │ ├── calc.js │ │ ├── cluster.js │ │ ├── event.js │ │ ├── index.js │ │ ├── props.js │ │ └── react.js ├── components │ ├── MappedComponent.js │ ├── MarkerLayer │ │ ├── Component.js │ │ └── index.js │ └── ReactMapboxGlCluster │ │ ├── ClusterLayer.css │ │ ├── ClusterLayer.js │ │ ├── MapboxGlCluster.js │ │ ├── index.js │ │ └── package.json ├── index.js └── types │ ├── common │ ├── constants │ │ ├── ClusterOptions.d.ts │ │ └── GeoJSONTypes.d.ts │ ├── hoc │ │ ├── connectWithSpiderifierPoint.d.ts │ │ ├── detectLocationHasOverlappedPoints.d.ts │ │ ├── doZoomingOnClick.d.ts │ │ └── index.d.ts │ └── utils │ │ ├── calc.d.ts │ │ ├── cluster.d.ts │ │ ├── event.d.ts │ │ ├── index.d.ts │ │ ├── props.d.ts │ │ └── react.d.ts │ ├── components │ ├── MappedComponent.d.ts │ ├── MarkerLayer │ │ ├── Component.d.ts │ │ └── index.d.ts │ └── ReactMapboxGlCluster │ │ ├── ClusterLayer.d.ts │ │ ├── MapboxGlCluster.d.ts │ │ └── index.d.ts │ └── index.d.ts ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.js ├── App.test.js ├── data.js ├── index.css ├── index.js ├── lib │ ├── common │ │ ├── constants │ │ │ ├── ClusterOptions.js │ │ │ └── GeoJSONTypes.js │ │ ├── hoc │ │ │ ├── connectWithSpiderifierPoint.jsx │ │ │ ├── detectLocationHasOverlappedPoints.jsx │ │ │ ├── doZoomingOnClick.jsx │ │ │ ├── index.js │ │ │ └── spiderifier.css │ │ └── utils │ │ │ ├── calc.js │ │ │ ├── cluster.js │ │ │ ├── event.js │ │ │ ├── index.js │ │ │ ├── props.js │ │ │ └── react.js │ ├── components │ │ ├── MappedComponent.jsx │ │ ├── MarkerLayer │ │ │ ├── Component.jsx │ │ │ └── index.jsx │ │ └── ReactMapboxGlCluster │ │ │ ├── ClusterLayer.css │ │ │ ├── ClusterLayer.jsx │ │ │ ├── MapboxGlCluster.jsx │ │ │ ├── index.js │ │ │ └── package.json │ └── index.js ├── logo.svg ├── react-app-env.d.ts └── serviceWorker.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn.lock 22 | yarn-debug.log* 23 | yarn-error.log* 24 | /.history 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Thuan Bui 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-mapbox-gl-cluster 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/thuanmb/react-mapbox-gl-cluster/blob/master/LICENSE) 4 | [![npm downloads](https://img.shields.io/npm/dm/react-mapbox-gl-cluster.svg)](https://www.npmjs.com/package/react-mapbox-gl-cluster) 5 | 6 | The `React` component for the cluster layer in the `mapbox-gl`. 7 | 8 | The cluster layer has some build-in actions: 9 | 10 | 1. Zoom in when clicking on a cluster. 11 | 2. Show the spiderifiers when clicking on a cluster which contains the points at same location. 12 | 13 | This layer must be rendered inside `react-mapbox-gl` component. 14 | 15 | ## Examples: 16 | 17 | - https://github.com/thuanmb/react-mapbox-gl-cluster/blob/master/src/App.js 18 | 19 | ![Demo Cluster.](./demo/demo.gif) 20 | 21 | Please note that the `ReactMapboxGlCluster` should be used together with the `React` wrapper of `mapbox-gl` e.g. `react-mapbox-gl`. 22 | https://github.com/alex3165/react-mapbox-gl 23 | 24 | ```js 25 | import React, {Component} from 'react'; 26 | import ReactMapboxGl from 'react-mapbox-gl'; 27 | import {ReactMapboxGlCluster} from 'react-mapbox-gl-cluster'; 28 | import {data} from './data'; 29 | 30 | const Map = ReactMapboxGl({ 31 | accessToken: process.env.MAPBOX_GL_TOKEN, 32 | }); 33 | 34 | const mapProps = { 35 | center: [-95.7129, 37.0902], 36 | zoom: [3], 37 | style: 'mapbox://styles/mapbox/streets-v8', 38 | }; 39 | 40 | class App extends Component { 41 | getEventHandlers() { 42 | return { 43 | onClick: (properties, coords, offset) => 44 | console.log(`Receive event onClick at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 45 | onMouseEnter: (properties, coords, offset) => 46 | console.log(`Receive event onMouseEnter at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 47 | onMouseLeave: (properties, coords, offset) => 48 | console.log(`Receive event onMouseLeave at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 49 | onClusterClick: (properties, coords, offset) => 50 | console.log(`Receive event onClusterClick at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 51 | }; 52 | } 53 | 54 | render() { 55 | return ( 56 |
57 | 58 | 59 | 60 |
61 | ); 62 | } 63 | } 64 | ``` 65 | 66 | ## Documentations 67 | 68 | #### Properties 69 | 70 | - `data (object)` 71 | Data source for layer. It must to follow FeatureCollection geojson format 72 | 73 | - `radius (number)` 74 | [Optional] Cluster radius, in pixels. 75 | 76 | - `minZoom (number)` 77 | [Optional] Minimum zoom level at which clusters are generated. 78 | 79 | - `maxZoom (number)` 80 | [Optional] Maximum zoom level at which clusters are generated. 81 | 82 | - `extent (number)` 83 | [Optional](Tiles) Tile extent. Radius is calculated relative to this value. 84 | 85 | - `nodeSize (number)` 86 | [Optional] Size of the KD-tree leaf node. Affects performance. 87 | 88 | - `pointClassName (string)` 89 | [Optional] The class name of each point. 90 | 91 | - `pointStyles (object)` 92 | [Optional] The class name of each cluster. 93 | 94 | - `clusterClassName (string)` 95 | [Optional] The class name of each cluster. 96 | 97 | - `spiralComponent (element)` 98 | [Optional] The custom component for the spiral. Example usage: 99 | ``` 100 | const CustomSpiralComponent = ({properties, ...restProps}) => { 101 | const onClick = e => { 102 | console.log(`Receive event onClick in spiral at properties: ${JSON.stringify(properties)}`); 103 | }; 104 | return
; 105 | }; 106 | 107 | ... 108 | 109 | 110 | 111 | ``` 112 | 113 | - `markerComponent (element)` 114 | [Optional] The custom component for marker. Example usage: 115 | ``` 116 | const CustomeMarkerComponent = ({properties, className, cssObject}) => { 117 | const onClick = e => { 118 | console.log(`Receive event onClick in marker at properties: ${JSON.stringify(properties)}`); 119 | }; 120 | return
; 121 | }; 122 | ... 123 | 124 | 125 | 126 | ``` 127 | 128 | - clusterClickEnabled (bool) 129 | [Optional] Enable/disable zoom on cluster click 130 | 131 | #### Events 132 | 133 | - `onClick (function)` 134 | [Optional] Handler for when user on marker 135 | 136 | - `onMouseEnter (function)` 137 | [Optional] Handle when user move the mouse enter a point 138 | 139 | - `onMouseLeave (function)` 140 | [Optional] Handle when user move the mouse leave a point 141 | 142 | - `onClusterClick (function)` 143 | [Optional] Handle when user click on cluster 144 | 145 | - `onClusterMouseEnter (function)` 146 | [Optional] Handle when user move the mouse enter a cluster 147 | 148 | - `onClusterMouseLeave (function)` 149 | [Optional] Handle when user move the mouse leave a cluster 150 | 151 | ## ChangeLog: 152 | 153 | ### 1.20.0 154 | 155 | - Upgrading packages 156 | 157 | ### 1.19.0 158 | 159 | - Upgrading packages 160 | 161 | ### 1.18.0 162 | 163 | - Removing node-sass dependency 164 | 165 | ### 1.17.0 166 | 167 | - Fix TS compiling does not 168 | 169 | ### 1.15.0 170 | 171 | - Upgrading packages 172 | 173 | ### 1.14.0 174 | 175 | - Upgrading packages 176 | 177 | ### 1.12.0 178 | 179 | - Upgrading packages 180 | 181 | ### 1.11.0 182 | 183 | - Support `clusterClickEnabled` flag to enable/disable on cluster click event 184 | - Bumps depedencies version 185 | 186 | ### 1.10.0 187 | 188 | - Use caret version for react-mapbox-gl 189 | 190 | ### 1.7.0 191 | 192 | - Upgrading packages 193 | 194 | ### 1.6.0 195 | 196 | - Upgrading packages 197 | 198 | ### 1.5.0 199 | 200 | - Upgrading packages 201 | 202 | ### 1.4.0 203 | 204 | - Upgrading packages 205 | 206 | ### 1.3.0 207 | 208 | - Upgrading packages 209 | 210 | ### 1.2.1 211 | 212 | - Fix bundling issue. 213 | 214 | ### 1.2.0 215 | 216 | - Support custom marker. 217 | 218 | ``` 219 | const CustomeMarkerComponent = ({properties, className, cssObject}) => { 220 | const onClick = e => { 221 | console.log(`Receive event onClick in marker at properties: ${JSON.stringify(properties)}`); 222 | }; 223 | return
; 224 | }; 225 | ... 226 | 227 | 228 | 229 | ``` 230 | 231 | ### 1.1.0 232 | 233 | - Support custom spiral. 234 | 235 | ``` 236 | const CustomSpiralComponent = ({properties, ...restProps}) => { 237 | const onClick = e => { 238 | console.log(`Receive event onClick in spiral at properties: ${JSON.stringify(properties)}`); 239 | }; 240 | return
; 241 | }; 242 | 243 | ... 244 | 245 | 246 | 247 | ``` 248 | 249 | ### 1.0.0 250 | 251 | - Upgrading depedencies to latest version. 252 | 253 | ### 0.2.0 [BREAKING CHANGES] 254 | 255 | - Upgrade all packages to latest version. These packages include: `react`, `mapbox-gl`, `react-mapbox-gl`, `react-mapbox-gl-spiderifier`... 256 | 257 | ### 0.1.7 258 | 259 | - Fix JS error when click on a marker 260 | 261 | ## Development 262 | 263 | ### Starting the server in local 264 | - Adding the `.env` file 265 | - Adding the key `REACT_APP_MAPBOX_GL_TOKEN` into the `.env` file 266 | - Starting the server by: `yarn start` 267 | 268 | ## Upgrading dependencies checklist 269 | - Upgrading the dependencies 270 | ``` 271 | yarn upgrade-interactive --latest 272 | ``` 273 | - Pull the latest code 274 | - Create a new branch 275 | - Test if the app works after upgrading: `yarn start` 276 | - Build the package: `yarn build` 277 | - Increasing the package version in the `package.json` 278 | - Adding the release note in the `README` 279 | - Push the change into Github 280 | - Publish the package into npmjs: `npm publish` 281 | -------------------------------------------------------------------------------- /babelPresets.js: -------------------------------------------------------------------------------- 1 | module.exports = () => ({ 2 | presets: [[require("babel-preset-react-app"), { absoluteRuntime: false }]] 3 | }); 4 | -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thuanmb/react-mapbox-gl-cluster/c7ba16f3b3726aaf96e6583af57ff6f69a825dd2/demo/demo.gif -------------------------------------------------------------------------------- /dist/common/constants/ClusterOptions.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.ClusterOptions = void 0; 7 | const ClusterOptions = { 8 | NearestPointsRadius: 7, 9 | ZoomLevel: 17 10 | }; 11 | exports.ClusterOptions = ClusterOptions; -------------------------------------------------------------------------------- /dist/common/constants/GeoJSONTypes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.NormalTypes = exports.ListKeysByType = exports.GeoJSONTypes = exports.CollectionTypes = void 0; 7 | const GeoJSONTypes = { 8 | Point: "Point", 9 | MultiPoint: "MultiPoint", 10 | LineString: "LineString", 11 | MultiLineString: "MultiLineString", 12 | Polygon: "Polygon", 13 | MultiPolygon: "MultiPolygon", 14 | GeometryCollection: "GeometryCollection", 15 | FeatureCollection: "FeatureCollection" 16 | }; 17 | exports.GeoJSONTypes = GeoJSONTypes; 18 | const NormalTypes = [GeoJSONTypes.Point, GeoJSONTypes.MultiPoint, GeoJSONTypes.LineString, GeoJSONTypes.MultiLineString, GeoJSONTypes.Polygon, GeoJSONTypes.MultiPolygon]; 19 | exports.NormalTypes = NormalTypes; 20 | const CollectionTypes = [GeoJSONTypes.GeometryCollection, GeoJSONTypes.FeatureCollection]; 21 | exports.CollectionTypes = CollectionTypes; 22 | const ListKeysByType = { 23 | [GeoJSONTypes.GeometryCollection]: "geometries", 24 | [GeoJSONTypes.FeatureCollection]: "features" 25 | }; 26 | exports.ListKeysByType = ListKeysByType; -------------------------------------------------------------------------------- /dist/common/hoc/connectWithSpiderifierPoint.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _react = _interopRequireDefault(require("react")); 11 | 12 | var _propTypes = _interopRequireDefault(require("prop-types")); 13 | 14 | var _lodash = _interopRequireDefault(require("lodash")); 15 | 16 | var _utils = require("../utils"); 17 | 18 | var _reactMapboxGlSpiderifier = require("react-mapbox-gl-spiderifier"); 19 | 20 | var _invariant = require("@turf/invariant"); 21 | 22 | var _ClusterOptions = require("../constants/ClusterOptions"); 23 | 24 | var _MappedComponent = _interopRequireDefault(require("../../components/MappedComponent")); 25 | 26 | require("./spiderifier.css"); 27 | 28 | const SPIDERIFIER_PROPS = ["coordinates", "circleSpiralSwitchover", "circleFootSeparation", "spiralFootSeparation", "spiralLengthStart", "spiralLengthFactor", "animate", "animationSpeed", "transformSpiderLeft", "transformSpiderTop", "showingLegs", "onClick", "onMouseDown", "onMouseEnter", "onMouseLeave", "onMouseMove", "onMouseOut", "onMouseOver", "onMouseUp"]; 29 | const MARKER_PROPS = ["data", "radius", "minZoom", "maxZoom", "extent", "nodeSize", "pointClassName", "pointStyles", "clusterClassName", "clusterClassName", "markerComponent", "onMouseLeave", "onClick", "onClusterClick", "onClusterMouseEnter", "onClusterMouseLeave", "clusterClickEnabled"]; 30 | /** 31 | * @type Class 32 | */ 33 | 34 | const connectWithSpiderifierPoint = WrappedComponent => { 35 | class ConnectedWithSpiderifierComponent extends _MappedComponent.default { 36 | constructor(props) { 37 | super(props); 38 | 39 | this.onClickOverlappedPoints = (points, coordinates) => { 40 | this._updateSpiderifierProps([points], coordinates); 41 | }; 42 | 43 | this.onMapChange = () => { 44 | const { 45 | onlySpiderifier 46 | } = this.props; 47 | 48 | if (!onlySpiderifier && _lodash.default.isArray(this._spiderifieredLocations)) { 49 | const { 50 | data, 51 | radius 52 | } = this.props; 53 | const map = this.getMapInstance(); 54 | 55 | this._spiderifieredLocations.forEach(lngLat => { 56 | const points = (0, _utils.findPointsWithSameLocation)(data, lngLat, map, radius); 57 | 58 | if (!points) { 59 | this.onSpiderifierRemoved(lngLat); 60 | } 61 | }); 62 | } 63 | }; 64 | 65 | this.state = { 66 | overlappedPointsGroup: null 67 | }; 68 | this.registeredEvents = false; 69 | } 70 | 71 | componentDidUpdate(prevProps) { 72 | this._checkAndUpdatePoints(prevProps); 73 | 74 | this.bindEvents(); 75 | } 76 | 77 | shouldComponentUpdate(nextProps, nextState) { 78 | return (0, _utils.checkPropsChange)(this.props, nextProps, ["data", "showInitialSpiderifier", "onlySpiderifier", "circleFootSeparation", "transformSpiderLeft", "showingLegs"], _lodash.default.isEqual) || !_lodash.default.isEqual(this.state, nextState); 79 | } 80 | 81 | componentWillUnmount() { 82 | this.unbindEvents(); 83 | } 84 | 85 | bindEvents() { 86 | const map = this.getMapInstance(); 87 | 88 | if (map && !this.registeredEvents) { 89 | map.on("zoomend", this.onMapChange); 90 | this.registeredEvents = true; 91 | } 92 | } 93 | 94 | unbindEvents() { 95 | const map = this.getMapInstance(); 96 | 97 | if (map) { 98 | map.off("zoomend", this.onMapChange); 99 | } 100 | } 101 | 102 | onSpiderifierRemoved(lngLat) { 103 | const { 104 | overlappedPointsGroup 105 | } = this.state; 106 | 107 | if (_lodash.default.isArray(overlappedPointsGroup)) { 108 | const removedIndex = overlappedPointsGroup.findIndex(_ref => { 109 | let { 110 | coordinates 111 | } = _ref; 112 | return _lodash.default.isEqual(coordinates, lngLat); 113 | }); 114 | 115 | if (removedIndex > -1) { 116 | const newGroup = [...overlappedPointsGroup.slice(0, removedIndex), ...overlappedPointsGroup.slice(removedIndex + 1)]; 117 | this.setState({ 118 | overlappedPointsGroup: newGroup 119 | }); 120 | } 121 | } 122 | 123 | const { 124 | onSpiderifierRemoved 125 | } = this.props; 126 | 127 | if (_lodash.default.isFunction(onSpiderifierRemoved)) { 128 | onSpiderifierRemoved(lngLat); 129 | } 130 | } 131 | 132 | _checkAndUpdatePoints(prevProps) { 133 | if ((0, _utils.checkPropsChange)(this.props, prevProps, ["data", "showInitialSpiderifier", "onlySpiderifier"], _lodash.default.isEqual)) { 134 | this._updatePoints(); 135 | } 136 | } 137 | 138 | _getComponentProps(keys) { 139 | return _lodash.default.pick(this.props, keys); 140 | } 141 | 142 | _getWrappedComponentProps() { 143 | return this._getComponentProps(MARKER_PROPS); 144 | } 145 | 146 | _getSpiderifierComponentProps() { 147 | return this._getComponentProps(SPIDERIFIER_PROPS); 148 | } 149 | 150 | _groupNearestPoint(props) { 151 | const { 152 | data, 153 | showInitialSpiderifier, 154 | onlySpiderifier 155 | } = props; 156 | const map = this.getMapInstance(); 157 | const groupedPoints = (0, _utils.groupNearestPointsByRadius)(data, map, _ClusterOptions.ClusterOptions.NearestPointsRadius); 158 | 159 | if (groupedPoints.length > 0) { 160 | if (onlySpiderifier && groupedPoints.length === 1) { 161 | this._updateSpiderifierProps(groupedPoints); 162 | } else if (showInitialSpiderifier) { 163 | let firstGroup = groupedPoints.find(group => group.length > 1); 164 | 165 | if (firstGroup == null) { 166 | firstGroup = groupedPoints[0]; 167 | } 168 | 169 | this._updateSpiderifierProps([firstGroup]); 170 | } 171 | } 172 | } 173 | 174 | _processSpiderifyProperties(props) { 175 | const { 176 | spiderifyPropsProcessor 177 | } = this.props; 178 | 179 | if (_lodash.default.isFunction(spiderifyPropsProcessor)) { 180 | return spiderifyPropsProcessor(props); 181 | } 182 | 183 | return props; 184 | } 185 | 186 | _renderSpiderifierContent(key, properties) { 187 | const { 188 | spiralComponent: SpiralComponent 189 | } = this.props; 190 | 191 | if (SpiralComponent) { 192 | return /*#__PURE__*/_react.default.createElement(SpiralComponent, { 193 | key: key, 194 | properties: properties 195 | }); 196 | } 197 | 198 | return /*#__PURE__*/_react.default.createElement("div", { 199 | className: "spiderifier-marker-content", 200 | key: key, 201 | properties: properties 202 | }, /*#__PURE__*/_react.default.createElement("div", null, properties.label)); 203 | } 204 | 205 | _renderSpiderifier() { 206 | const { 207 | overlappedPointsGroup 208 | } = this.state; 209 | 210 | if (overlappedPointsGroup && overlappedPointsGroup.length > 0) { 211 | const spiderifierComponentProps = this._getSpiderifierComponentProps(); 212 | 213 | return overlappedPointsGroup.map((overlappedPoints, index) => { 214 | const { 215 | coordinates, 216 | markers 217 | } = overlappedPoints; 218 | return /*#__PURE__*/_react.default.createElement(_reactMapboxGlSpiderifier.ReactMapboxGlSpiderifier, Object.assign({ 219 | key: index 220 | }, spiderifierComponentProps, { 221 | coordinates: coordinates 222 | }), markers.map((marker, index) => this._renderSpiderifierContent(index, marker))); 223 | }); 224 | } 225 | 226 | return null; 227 | } 228 | 229 | _shouldRenderClusterLayer() { 230 | const { 231 | onlySpiderifier, 232 | overlappedPointsGroup 233 | } = this.props; 234 | return !onlySpiderifier || !overlappedPointsGroup || overlappedPointsGroup.length > 1; 235 | } 236 | 237 | _updatePoints() { 238 | let props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.props; 239 | const { 240 | data, 241 | showInitialSpiderifier, 242 | onlySpiderifier 243 | } = props; 244 | 245 | if (data != null && (showInitialSpiderifier || onlySpiderifier)) { 246 | this._groupNearestPoint(props); 247 | } 248 | } 249 | 250 | _updateSpiderifierProps(group, coordinates) { 251 | this._spiderifieredLocations = []; 252 | 253 | if (group.length > 0) { 254 | const overlappedPointsGroup = group.map(points => { 255 | if (points.length > 0) { 256 | const properties = points.map(feature => feature.properties); 257 | let coords = coordinates; 258 | 259 | if (coords == null) { 260 | coords = (0, _invariant.getCoord)(points[0]); 261 | } 262 | 263 | return { 264 | markers: this._processSpiderifyProperties(properties), 265 | coordinates: coords 266 | }; 267 | } 268 | 269 | return null; 270 | }); 271 | const { 272 | onShowSpiderifier 273 | } = this.props; 274 | overlappedPointsGroup.forEach(group => { 275 | const { 276 | coordinates, 277 | markers 278 | } = group; 279 | 280 | this._spiderifieredLocations.push(coordinates); 281 | 282 | if (_lodash.default.isFunction(onShowSpiderifier)) { 283 | onShowSpiderifier(coordinates, markers); 284 | } 285 | }); 286 | this.setState({ 287 | overlappedPointsGroup 288 | }); 289 | } 290 | } 291 | 292 | render() { 293 | const wrappedComponentProps = this._getWrappedComponentProps(); 294 | 295 | return /*#__PURE__*/_react.default.createElement("div", null, this._shouldRenderClusterLayer() && /*#__PURE__*/_react.default.createElement(WrappedComponent, Object.assign({}, wrappedComponentProps, { 296 | onClickOverlappedPoints: this.onClickOverlappedPoints 297 | })), this._renderSpiderifier()); 298 | } 299 | 300 | } 301 | 302 | ConnectedWithSpiderifierComponent.propTypes = { 303 | /** 304 | * Indicate if the spiderifier should be shown for the first overlapped point onload 305 | */ 306 | showInitialSpiderifier: _propTypes.default.bool, 307 | 308 | /** 309 | * Indicate if the spiderifier should be shown without wrapped component 310 | */ 311 | onlySpiderifier: _propTypes.default.bool, 312 | 313 | /** 314 | * Handler to transform the properties of each point 315 | */ 316 | spiderifyPropsProcessor: _propTypes.default.func, 317 | 318 | /** 319 | * Callback when a spiderifier shown 320 | */ 321 | onShowSpiderifier: _propTypes.default.func, 322 | 323 | /** 324 | * [Optional] Handle when user do zoom/move to change the map and made the points 325 | * on the map changed and don't have overlapped points anymore 326 | */ 327 | onSpiderifierRemoved: _propTypes.default.func, 328 | 329 | /** 330 | * Allow to customize the spiral component 331 | */ 332 | spiralComponent: _propTypes.default.oneOfType([_propTypes.default.element, _propTypes.default.func]) 333 | }; 334 | ConnectedWithSpiderifierComponent.defaultProps = { ...WrappedComponent.defaultProps, 335 | ..._reactMapboxGlSpiderifier.ReactMapboxGlSpiderifier.defaultProps 336 | }; 337 | return ConnectedWithSpiderifierComponent; 338 | }; 339 | 340 | var _default = connectWithSpiderifierPoint; 341 | exports.default = _default; -------------------------------------------------------------------------------- /dist/common/hoc/detectLocationHasOverlappedPoints.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _react = _interopRequireDefault(require("react")); 11 | 12 | var _propTypes = _interopRequireDefault(require("prop-types")); 13 | 14 | var _lodash = _interopRequireDefault(require("lodash")); 15 | 16 | var _helpers = require("@turf/helpers"); 17 | 18 | var _utils = require("../utils"); 19 | 20 | var _ClusterOptions = require("../constants/ClusterOptions"); 21 | 22 | var _MappedComponent = _interopRequireDefault(require("../../components/MappedComponent")); 23 | 24 | /** 25 | * @type Class 26 | */ 27 | const detectLocationHasOverlappedPoints = WrappedComponent => { 28 | class LayerWithOverlappedPointComponent extends _MappedComponent.default { 29 | constructor() { 30 | super(...arguments); 31 | 32 | this.onClick = (properties, lngLat, event, meta) => { 33 | const { 34 | onClick 35 | } = this.props; 36 | 37 | this._handleClick(properties, lngLat, event, meta, onClick); 38 | }; 39 | 40 | this.onClusterClick = (properties, lngLat, event, meta) => { 41 | const { 42 | onClusterClick 43 | } = this.props; 44 | 45 | this._handleClick(properties, lngLat, event, meta, onClusterClick); 46 | }; 47 | } 48 | 49 | _handleClick(properties, lngLat, event, meta, callback) { 50 | if (!_lodash.default.isArray(properties)) { 51 | if (_lodash.default.isFunction(callback)) { 52 | callback(properties, lngLat, event, meta); 53 | } 54 | 55 | return true; 56 | } 57 | 58 | const { 59 | onClickOverlappedPoints 60 | } = this.props; 61 | const map = this.getMapInstance(); 62 | const features = properties.map(prop => (0, _helpers.point)(prop.coordinates, prop)); 63 | const data = (0, _helpers.featureCollection)(features); 64 | const points = (0, _utils.findPointsWithSameLocation)(data, lngLat, map, _ClusterOptions.ClusterOptions.NearestPointsRadius, _ClusterOptions.ClusterOptions.ZoomLevel); 65 | 66 | if (points) { 67 | if (_lodash.default.isFunction(onClickOverlappedPoints)) { 68 | onClickOverlappedPoints(features, lngLat, event, meta); 69 | return false; 70 | } 71 | } else if (_lodash.default.isFunction(callback)) { 72 | callback(properties, lngLat, event, meta); 73 | } 74 | 75 | return true; 76 | } 77 | 78 | render() { 79 | const props = { ...this.props, 80 | onClick: this.onClick, 81 | onClusterClick: this.onClusterClick 82 | }; 83 | return /*#__PURE__*/_react.default.createElement(WrappedComponent, props); 84 | } 85 | 86 | } 87 | 88 | LayerWithOverlappedPointComponent.propTypes = { 89 | /** 90 | * [Optional] Handle when user click on a location which has overlapped points 91 | */ 92 | onClickOverlappedPoints: _propTypes.default.func 93 | }; 94 | LayerWithOverlappedPointComponent.defaultProps = { ...WrappedComponent.defaultProps 95 | }; 96 | return LayerWithOverlappedPointComponent; 97 | }; 98 | 99 | var _default = detectLocationHasOverlappedPoints; 100 | exports.default = _default; -------------------------------------------------------------------------------- /dist/common/hoc/doZoomingOnClick.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _react = _interopRequireDefault(require("react")); 11 | 12 | var _propTypes = _interopRequireDefault(require("prop-types")); 13 | 14 | var _lodash = _interopRequireDefault(require("lodash")); 15 | 16 | var _MappedComponent = _interopRequireDefault(require("../../components/MappedComponent")); 17 | 18 | var _utils = require("../utils"); 19 | 20 | /** 21 | * @type Class 22 | */ 23 | const doZoomingOnClick = WrappedComponent => { 24 | class ZoomableComponent extends _MappedComponent.default { 25 | constructor() { 26 | super(...arguments); 27 | 28 | this.onClusterClick = (properties, lngLat, event, meta) => { 29 | const { 30 | onClusterClick, 31 | clusterClickEnabled 32 | } = this.props; 33 | 34 | if (!clusterClickEnabled) { 35 | return; 36 | } 37 | 38 | const map = this.getMapInstance(); 39 | const currentZoom = map.getZoom(); 40 | const maxZoom = map.getMaxZoom(); 41 | const zoom = (0, _utils.calculateNextZoomLevel)(currentZoom, maxZoom); 42 | map.flyTo({ 43 | center: lngLat, 44 | zoom 45 | }); 46 | 47 | this._handleClick(properties, lngLat, event, meta, onClusterClick); 48 | }; 49 | } 50 | 51 | _handleClick(properties, lngLat, event, meta, callback) { 52 | if (_lodash.default.isFunction(callback)) { 53 | callback(properties, lngLat, event, meta); 54 | } 55 | } 56 | 57 | render() { 58 | const props = { ...this.props, 59 | onClusterClick: this.onClusterClick 60 | }; 61 | return /*#__PURE__*/_react.default.createElement(WrappedComponent, props); 62 | } 63 | 64 | } 65 | 66 | ZoomableComponent.propTypes = { 67 | clusterClickEnabled: _propTypes.default.bool 68 | }; 69 | ZoomableComponent.defaultProps = { ...WrappedComponent.defaultProps, 70 | clusterClickEnabled: true 71 | }; 72 | return ZoomableComponent; 73 | }; 74 | 75 | var _default = doZoomingOnClick; 76 | exports.default = _default; -------------------------------------------------------------------------------- /dist/common/hoc/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | Object.defineProperty(exports, "connectWithSpiderifierPoint", { 9 | enumerable: true, 10 | get: function () { 11 | return _connectWithSpiderifierPoint.default; 12 | } 13 | }); 14 | Object.defineProperty(exports, "detectLocationHasOverlappedPoints", { 15 | enumerable: true, 16 | get: function () { 17 | return _detectLocationHasOverlappedPoints.default; 18 | } 19 | }); 20 | Object.defineProperty(exports, "doZoomingOnClick", { 21 | enumerable: true, 22 | get: function () { 23 | return _doZoomingOnClick.default; 24 | } 25 | }); 26 | 27 | var _connectWithSpiderifierPoint = _interopRequireDefault(require("./connectWithSpiderifierPoint")); 28 | 29 | var _detectLocationHasOverlappedPoints = _interopRequireDefault(require("./detectLocationHasOverlappedPoints")); 30 | 31 | var _doZoomingOnClick = _interopRequireDefault(require("./doZoomingOnClick")); -------------------------------------------------------------------------------- /dist/common/hoc/spiderifier.css: -------------------------------------------------------------------------------- 1 | .spiderifier-marker-content { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 28px; 6 | height: 28px; 7 | color: rgb(255, 255, 255); 8 | background-color: rgb(195, 38, 94); 9 | border-color: rgb(255, 255, 255); 10 | border-width: 1px; 11 | border-radius: 50%; 12 | margin-left: -14px; 13 | margin-top: -14px; 14 | box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.29); 15 | font-size: 8px; 16 | font-weight: bold; 17 | text-align: center; 18 | border: 1px solid; 19 | } 20 | -------------------------------------------------------------------------------- /dist/common/utils/calc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.calculateNextZoomLevel = void 0; 7 | 8 | /** 9 | * Calculate the next zoom level base on the current zoom 10 | * @param {number} currentZoom the current zoom level 11 | * @param {number} maxZoom the max zoom level of the map 12 | * @param {number} extraZoomLevels how many extra level more for each zoom 13 | */ 14 | const calculateNextZoomLevel = function (currentZoom) { 15 | let maxZoom = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 20; 16 | let extraZoomLevels = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 2; 17 | 18 | if (currentZoom >= 14 && currentZoom < maxZoom - 2) { 19 | return maxZoom - 2; 20 | } 21 | 22 | if (currentZoom >= maxZoom - 2) { 23 | return maxZoom; 24 | } 25 | 26 | const delta = maxZoom - currentZoom; 27 | const percentage = delta / maxZoom; 28 | const zoom = currentZoom + extraZoomLevels * percentage + extraZoomLevels * Math.pow(percentage, 2) + extraZoomLevels * Math.pow(percentage, 3); 29 | return zoom; 30 | }; 31 | 32 | exports.calculateNextZoomLevel = calculateNextZoomLevel; -------------------------------------------------------------------------------- /dist/common/utils/cluster.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.groupNearestPointsByRadius = exports.findPointsWithSameLocation = exports.createClusters = void 0; 9 | 10 | var _lodash = _interopRequireDefault(require("lodash")); 11 | 12 | var _supercluster = _interopRequireDefault(require("supercluster")); 13 | 14 | var _invariant = require("@turf/invariant"); 15 | 16 | var _mapboxGl = require("mapbox-gl"); 17 | 18 | var _GeoJSONTypes = require("../constants/GeoJSONTypes"); 19 | 20 | const RADIUS_TO_EXTENDS = 200; 21 | 22 | const checkCollectionGeoJSON = data => _GeoJSONTypes.CollectionTypes.indexOf(data.type) !== -1; 23 | 24 | const createBoundsFromCoordinates = (coordinates, bounds) => { 25 | if (bounds == null) { 26 | return new _mapboxGl.LngLatBounds(coordinates, coordinates); 27 | } 28 | 29 | return bounds.extend(coordinates); 30 | }; 31 | 32 | const extendBounds = function (boundary) { 33 | let radius = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100; 34 | const boundObj = new _mapboxGl.LngLatBounds(boundary); 35 | const ne = boundObj.getNorthEast(); 36 | const neBound = ne.toBounds(radius / 2); 37 | const sw = boundObj.getSouthWest(); 38 | const swBound = sw.toBounds(radius / 2); 39 | return _lodash.default.flatten([swBound.getSouthWest().toArray(), neBound.getNorthEast().toArray()]); 40 | }; 41 | 42 | const flattenCoordinates = (coordinates, positionType) => { 43 | let depth; 44 | 45 | switch (positionType) { 46 | case _GeoJSONTypes.GeoJSONTypes.MultiPoint: 47 | case _GeoJSONTypes.GeoJSONTypes.LineString: 48 | depth = 0; 49 | break; 50 | 51 | case _GeoJSONTypes.GeoJSONTypes.Polygon: 52 | case _GeoJSONTypes.GeoJSONTypes.MultiLineString: 53 | depth = 1; 54 | break; 55 | 56 | case _GeoJSONTypes.GeoJSONTypes.MultiPolygon: 57 | depth = 2; 58 | break; 59 | 60 | case _GeoJSONTypes.GeoJSONTypes.Point: 61 | default: 62 | depth = -1; 63 | } 64 | 65 | if (depth === -1) { 66 | return [coordinates]; 67 | } 68 | 69 | return _lodash.default.flattenDepth(coordinates, depth); 70 | }; 71 | 72 | const getCoordinateForPosition = function (position) { 73 | let geoJSONType = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : _GeoJSONTypes.GeoJSONTypes.FeatureCollection; 74 | 75 | if (geoJSONType === _GeoJSONTypes.GeoJSONTypes.FeatureCollection) { 76 | return position.geometry.coordinates; 77 | } 78 | 79 | return position.coordinates; 80 | }; 81 | 82 | const getFeatureList = geoJSON => { 83 | const { 84 | type 85 | } = geoJSON; 86 | const key = _GeoJSONTypes.ListKeysByType[type]; 87 | return geoJSON[key]; 88 | }; 89 | 90 | const getTypeForPosition = (position, geoJSONType) => { 91 | if (geoJSONType === _GeoJSONTypes.GeoJSONTypes.FeatureCollection) { 92 | return position.geometry.type; 93 | } 94 | 95 | return position.type; 96 | }; 97 | 98 | const roundCoords = coords => [_lodash.default.round(coords[0], 4), _lodash.default.round(coords[1], 4)]; 99 | /** 100 | * Calculate the boundary of a geojson 101 | * @param {object} data a geojson in any format 102 | * @param? {*} totalBounds [Optional] if given, the boundary will be calculated base on the current "totalBounds" 103 | * @return {LngLatBounds} the total boundary 104 | */ 105 | 106 | 107 | const calculateBoundary = function (data) { 108 | let totalBounds = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; 109 | const { 110 | type 111 | } = data; 112 | 113 | if (checkCollectionGeoJSON(data)) { 114 | const features = getFeatureList(data); 115 | features.forEach(feature => { 116 | let coordinates = getCoordinateForPosition(feature, type); 117 | let featureType = getTypeForPosition(feature, type); 118 | coordinates = flattenCoordinates(coordinates, featureType); 119 | 120 | if (!_lodash.default.isArray(coordinates)) { 121 | return totalBounds; 122 | } 123 | 124 | if (!totalBounds) { 125 | totalBounds = new _mapboxGl.LngLatBounds(coordinates[0], coordinates[0]); 126 | } 127 | 128 | totalBounds = coordinates.reduce(function (bounds, coord) { 129 | return bounds.extend(coord); 130 | }, totalBounds); 131 | }); 132 | return totalBounds; 133 | } 134 | 135 | const coordinates = (0, _invariant.getCoord)(data); 136 | return createBoundsFromCoordinates(coordinates, totalBounds); 137 | }; 138 | /** 139 | * Find the list of point that inside a specific radius 140 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 141 | * @param {MapBox} mapBox Required. The mapbox instance 142 | * @param {number} zoom The zoom level, at which the points is clustered 143 | * @return {Array} The list of feature 144 | */ 145 | 146 | 147 | const createClusters = function (data, mapBox) { 148 | let radius = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 60; 149 | let zoom = arguments.length > 3 ? arguments[3] : undefined; 150 | 151 | if (!data || !data.features || !_lodash.default.isArray(data.features)) { 152 | throw new Error("Data cannot be empty"); 153 | } 154 | 155 | if (!mapBox) { 156 | throw new Error("Mapbox instance must be provided"); 157 | } 158 | 159 | const superC = new _supercluster.default({ 160 | radius, 161 | maxZoom: mapBox.getMaxZoom() 162 | }); 163 | const featureList = getFeatureList(data); 164 | superC.load(featureList); 165 | 166 | if (!zoom) { 167 | zoom = mapBox.getZoom(); 168 | } 169 | 170 | let boundary = _lodash.default.isEmpty(featureList) ? [0, 0, 0, 0] : _lodash.default.flatten(calculateBoundary(data).toArray()); // in case of all points at the same location, 171 | // extends its coords by 200 meters radius to make superC work. 172 | 173 | boundary = extendBounds(boundary, RADIUS_TO_EXTENDS); 174 | const clusters = featureList.length > 1 ? superC.getClusters(boundary, Math.round(zoom)) : featureList; 175 | return { 176 | superC, 177 | clusters 178 | }; 179 | }; 180 | /** 181 | * Find the list of point that have a similar location (lngLat) 182 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 183 | * @param {Coordinate} lngLat Required. The coordinate follow format [longitude, latitude] 184 | * @param {MapBox} mapBox Required. The mapbox instance 185 | * @param {number} radius The radius of the cluster 186 | * @param {number} zoom The zoom level, at which the points is clustered 187 | * @return {Array} The list of point at the same location. Null if cannot find the 188 | * similar points 189 | */ 190 | 191 | 192 | exports.createClusters = createClusters; 193 | 194 | const findPointsWithSameLocation = function (data, lngLat, mapBox) { 195 | let radius = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 5; 196 | let zoom = arguments.length > 4 ? arguments[4] : undefined; 197 | 198 | if (!data || !data.features || !_lodash.default.isArray(data.features)) { 199 | throw new Error("Data cannot be empty"); 200 | } 201 | 202 | if (!lngLat || !_lodash.default.isArray(lngLat)) { 203 | throw new Error("Specific location cannot be empty"); 204 | } 205 | 206 | if (!mapBox) { 207 | throw new Error("Mapbox instance must be provided"); 208 | } 209 | 210 | const { 211 | clusters, 212 | superC 213 | } = createClusters(data, mapBox, radius, zoom); 214 | const clusterAtLngLat = clusters.find(cluster => _lodash.default.isEqual(roundCoords(cluster.geometry.coordinates), roundCoords(lngLat))); 215 | 216 | if (clusterAtLngLat) { 217 | const { 218 | cluster, 219 | cluster_id, 220 | point_count 221 | } = clusterAtLngLat.properties; 222 | 223 | if (cluster && point_count > 1) { 224 | try { 225 | return superC.getLeaves(cluster_id, Infinity); 226 | } catch (e) { 227 | return null; 228 | } 229 | } 230 | } 231 | 232 | return null; 233 | }; 234 | /** 235 | * Group the list of point that inside a specific radius 236 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 237 | * @param {MapBox} mapBox Required. The mapbox instance 238 | * @param {number} radius Optional. The radius of the cluster 239 | * @return {Array>} The list of grouped feature 240 | */ 241 | 242 | 243 | exports.findPointsWithSameLocation = findPointsWithSameLocation; 244 | 245 | const groupNearestPointsByRadius = function (data, mapBox) { 246 | let radius = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 60; 247 | 248 | if (!data || !data.features || !_lodash.default.isArray(data.features)) { 249 | throw new Error("Data cannot be empty"); 250 | } 251 | 252 | if (!mapBox) { 253 | throw new Error("Mapbox instance must be provided"); 254 | } 255 | 256 | const zoom = mapBox.getMaxZoom() - 2; 257 | let { 258 | clusters, 259 | superC 260 | } = createClusters(data, mapBox, radius, zoom); 261 | clusters = clusters.map(cluster => { 262 | const { 263 | cluster: isCluster, 264 | cluster_id 265 | } = cluster.properties; 266 | 267 | if (isCluster) { 268 | try { 269 | return superC.getLeaves(cluster_id, Infinity); 270 | } catch (e) { 271 | return null; 272 | } 273 | } 274 | 275 | return [cluster]; 276 | }); 277 | return _lodash.default.filter(clusters); 278 | }; 279 | 280 | exports.groupNearestPointsByRadius = groupNearestPointsByRadius; -------------------------------------------------------------------------------- /dist/common/utils/event.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.getExactEventHandlerName = exports.extractEventHandlers = void 0; 9 | 10 | var _lodash = _interopRequireDefault(require("lodash")); 11 | 12 | const EVENT_PREFIX = /^on(.+)$/i; 13 | 14 | const extractEventHandlers = function (props) { 15 | let eventPrefix = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : EVENT_PREFIX; 16 | return _lodash.default.reduce(Object.keys(props), (res, prop) => { 17 | const cb = props[prop]; 18 | 19 | if (eventPrefix.test(prop) && _lodash.default.isFunction(cb)) { 20 | const key = prop.replace(eventPrefix, (match, p) => `on${p}`); 21 | res[key] = cb; 22 | } 23 | 24 | return res; 25 | }, {}); 26 | }; 27 | 28 | exports.extractEventHandlers = extractEventHandlers; 29 | 30 | const getExactEventHandlerName = event => { 31 | if (!_lodash.default.isString(event)) { 32 | return event; 33 | } 34 | 35 | return event.replace("on", "").toLowerCase(); 36 | }; 37 | 38 | exports.getExactEventHandlerName = getExactEventHandlerName; -------------------------------------------------------------------------------- /dist/common/utils/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | Object.defineProperty(exports, "calculateNextZoomLevel", { 7 | enumerable: true, 8 | get: function () { 9 | return _calc.calculateNextZoomLevel; 10 | } 11 | }); 12 | Object.defineProperty(exports, "checkPropsChange", { 13 | enumerable: true, 14 | get: function () { 15 | return _props.checkPropsChange; 16 | } 17 | }); 18 | Object.defineProperty(exports, "createClusters", { 19 | enumerable: true, 20 | get: function () { 21 | return _cluster.createClusters; 22 | } 23 | }); 24 | Object.defineProperty(exports, "extractEventHandlers", { 25 | enumerable: true, 26 | get: function () { 27 | return _event.extractEventHandlers; 28 | } 29 | }); 30 | Object.defineProperty(exports, "findPointsWithSameLocation", { 31 | enumerable: true, 32 | get: function () { 33 | return _cluster.findPointsWithSameLocation; 34 | } 35 | }); 36 | Object.defineProperty(exports, "getExactEventHandlerName", { 37 | enumerable: true, 38 | get: function () { 39 | return _event.getExactEventHandlerName; 40 | } 41 | }); 42 | Object.defineProperty(exports, "groupNearestPointsByRadius", { 43 | enumerable: true, 44 | get: function () { 45 | return _cluster.groupNearestPointsByRadius; 46 | } 47 | }); 48 | Object.defineProperty(exports, "isReactComponent", { 49 | enumerable: true, 50 | get: function () { 51 | return _react.isReactComponent; 52 | } 53 | }); 54 | 55 | var _calc = require("./calc"); 56 | 57 | var _cluster = require("./cluster"); 58 | 59 | var _event = require("./event"); 60 | 61 | var _props = require("./props"); 62 | 63 | var _react = require("./react"); -------------------------------------------------------------------------------- /dist/common/utils/props.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.checkPropsChange = void 0; 9 | 10 | var _lodash = _interopRequireDefault(require("lodash")); 11 | 12 | const checkPropsChange = function (props, nextProps, keys) { 13 | let equalityChecker = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : _lodash.default.isEqual; 14 | 15 | const propsToCheck = _lodash.default.pick(props, keys); 16 | 17 | const nextPropsToCheck = _lodash.default.pick(nextProps, keys); 18 | 19 | if (_lodash.default.isFunction(equalityChecker)) { 20 | return equalityChecker(propsToCheck, nextPropsToCheck); 21 | } 22 | 23 | return propsToCheck === nextPropsToCheck; 24 | }; 25 | 26 | exports.checkPropsChange = checkPropsChange; -------------------------------------------------------------------------------- /dist/common/utils/react.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.isReactComponent = void 0; 9 | 10 | var _react = _interopRequireDefault(require("react")); 11 | 12 | var _lodash = _interopRequireDefault(require("lodash")); 13 | 14 | const isFunctionComponent = component => component && _lodash.default.isFunction(component.type) && String(component.type).includes("createElement"); 15 | /** 16 | * Check if a component is a custom class React component or native DOM elements (e.g. div, span) 17 | * @param {*} component 18 | * @return {bool} True if the input component is React component 19 | */ 20 | 21 | 22 | const isReactComponent = component => { 23 | const isReactComponent = _lodash.default.get(component, "type.prototype.isReactComponent"); 24 | 25 | const isPureReactComponent = _lodash.default.get(component, "type.prototype.isPureReactComponent"); 26 | 27 | const isFunctionalComponent = isFunctionComponent(component); 28 | const isFragmentComponent = _lodash.default.toString(_lodash.default.get(component, "type")) === "Symbol(react.fragment)"; 29 | const isReactMemoComponent = _lodash.default.toString(_lodash.default.get(component, "$$typeof")) === "Symbol(react.memo)"; 30 | return isReactMemoComponent || /*#__PURE__*/_react.default.isValidElement(component) && (isReactComponent || isPureReactComponent || isFunctionalComponent || isFragmentComponent); 31 | }; 32 | 33 | exports.isReactComponent = isReactComponent; -------------------------------------------------------------------------------- /dist/components/MappedComponent.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _react = require("react"); 9 | 10 | var _reactMapboxGl = require("react-mapbox-gl"); 11 | 12 | class MappedComponent extends _react.Component { 13 | getMapInstance() { 14 | return this.context; 15 | } 16 | 17 | } 18 | 19 | MappedComponent.contextType = _reactMapboxGl.MapContext; 20 | var _default = MappedComponent; 21 | exports.default = _default; -------------------------------------------------------------------------------- /dist/components/MarkerLayer/Component.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _react = _interopRequireDefault(require("react")); 11 | 12 | var _reactDom = _interopRequireDefault(require("react-dom")); 13 | 14 | var _propTypes = _interopRequireDefault(require("prop-types")); 15 | 16 | var _lodash = _interopRequireDefault(require("lodash")); 17 | 18 | var _mapboxGl = _interopRequireDefault(require("mapbox-gl")); 19 | 20 | var _utils = require("../../common/utils"); 21 | 22 | var _MappedComponent = _interopRequireDefault(require("../MappedComponent")); 23 | 24 | class MarkerLayer extends _MappedComponent.default { 25 | constructor() { 26 | super(...arguments); 27 | 28 | this._disableMapDragPan = () => { 29 | const map = this.getMapInstance(); 30 | 31 | if (map) { 32 | map.dragPan.disable(); 33 | } 34 | }; 35 | 36 | this._enableMapDragPan = () => { 37 | const map = this.getMapInstance(); 38 | 39 | if (map) { 40 | map.dragPan.enable(); 41 | } 42 | }; 43 | 44 | this._generateEventHander = eventName => e => { 45 | const handler = this.props[eventName]; 46 | 47 | if (_lodash.default.isFunction(handler)) { 48 | const { 49 | coordinates 50 | } = this.props; 51 | const properties = this.getProperties(); 52 | handler(properties, coordinates, this.getOffset(), e); 53 | } 54 | }; 55 | } 56 | 57 | componentDidMount() { 58 | const node = this.attachChildren(this.props); 59 | this.layer = new _mapboxGl.default.Marker(node).setLngLat(this.props.coordinates).addTo(this.getMapInstance()); 60 | } 61 | 62 | componentDidUpdate(prevProps, prevState) { 63 | if (prevProps.coordinates !== this.props.coordinates) { 64 | this.layer.setLngLat(prevProps.coordinates); 65 | } 66 | 67 | if (prevProps.children !== this.props.children || (0, _utils.checkPropsChange)(this.props, prevProps, ["style", "className"])) { 68 | this.attachChildren(prevProps); 69 | } 70 | } 71 | 72 | componentWillUnmount() { 73 | if (!this.layer) { 74 | return; 75 | } 76 | 77 | this.layer.remove(); 78 | delete this.layer; 79 | } 80 | 81 | attachChildren() { 82 | let props = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.props; 83 | const { 84 | children 85 | } = props; 86 | 87 | if (children) { 88 | if (!this.element) { 89 | this.element = document.createElement("div"); 90 | } else { 91 | this._unbindEvents(); 92 | } 93 | 94 | const style = this.getStyle(this.props); 95 | this.element.className = this.getContainerClassName(props); 96 | Object.keys(style).forEach(s => { 97 | this.element.style[s] = style[s]; 98 | }); 99 | 100 | this._bindEvents(); 101 | 102 | const content = this.getContent(props); 103 | 104 | _reactDom.default.render(content, this.element); 105 | } 106 | 107 | return this.element; 108 | } 109 | 110 | getContainerClassName(props) { 111 | return `mapboxgl-marker ${props.className}`; 112 | } 113 | 114 | getContent(props) { 115 | const { 116 | children 117 | } = props; 118 | return /*#__PURE__*/_react.default.createElement("div", { 119 | className: "nio-marker-content f-width f-height" 120 | }, children); 121 | } 122 | 123 | getProperties() { 124 | return this.props.properties; 125 | } 126 | 127 | getOffset() { 128 | return [0, 0]; 129 | } 130 | 131 | getStyle(props) { 132 | return _lodash.default.clone(props.style) || {}; 133 | } 134 | 135 | _bindEvents() { 136 | const events = (0, _utils.extractEventHandlers)(this.props); 137 | this.realHandlers = {}; 138 | 139 | _lodash.default.forEach(events, (handler, name) => { 140 | const realHandler = this._generateEventHander(name); 141 | 142 | this.element.addEventListener((0, _utils.getExactEventHandlerName)(name), realHandler); 143 | this.realHandlers[name] = realHandler; 144 | }); 145 | 146 | this.element.addEventListener("mousedown", this._disableMapDragPan); 147 | this.element.addEventListener("mouseup", this._enableMapDragPan); 148 | } 149 | 150 | _unbindEvents() { 151 | const events = (0, _utils.extractEventHandlers)(this.props); 152 | this.element.removeEventListener("mousedown", this._disableMapDragPan); 153 | this.element.removeEventListener("mouseup", this._enableMapDragPan); 154 | 155 | _lodash.default.forEach(events, (handler, name) => { 156 | const realHandler = this.realHandlers[name]; 157 | this.element.removeEventListener((0, _utils.getExactEventHandlerName)(name), realHandler); 158 | }); 159 | 160 | delete this.realHandlers; 161 | } 162 | 163 | render() { 164 | return null; 165 | } 166 | 167 | } 168 | 169 | MarkerLayer.displayName = "MarkerLayer"; 170 | MarkerLayer.propTypes = { 171 | /** 172 | * (required): [number, number] Display the Marker at the given position 173 | */ 174 | coordinates: _propTypes.default.array.isRequired, 175 | 176 | /** 177 | * Properties of each Marker, will be passed back when events trigged 178 | */ 179 | properties: _propTypes.default.oneOfType([_propTypes.default.array.isRequired, _propTypes.default.object.isRequired, _propTypes.default.string.isRequired]), 180 | 181 | /** 182 | * Apply the className to the container of the Marker 183 | */ 184 | className: _propTypes.default.string, 185 | 186 | /** 187 | * Apply style to the Marker container 188 | */ 189 | style: _propTypes.default.object, 190 | 191 | /** 192 | * Child node(s) of the component, to be rendered as custom Marker 193 | */ 194 | children: _propTypes.default.oneOfType([_propTypes.default.node, _propTypes.default.arrayOf(_propTypes.default.node)]), 195 | 196 | /** 197 | * [Optional] The click event handler 198 | */ 199 | onClick: _propTypes.default.func, 200 | 201 | /** 202 | * [Optional] The mouse down event handler 203 | */ 204 | onMouseDown: _propTypes.default.func, 205 | 206 | /** 207 | * [Optional] The mouse enter event handler 208 | */ 209 | onMouseEnter: _propTypes.default.func, 210 | 211 | /** 212 | * [Optional] The mouse leave event handler 213 | */ 214 | onMouseLeave: _propTypes.default.func, 215 | 216 | /** 217 | * [Optional] The mouse move event handler 218 | */ 219 | onMouseMove: _propTypes.default.func, 220 | 221 | /** 222 | * [Optional] The mouse out event handler 223 | */ 224 | onMouseOut: _propTypes.default.func, 225 | 226 | /** 227 | * [Optional] The mouse over event handler 228 | */ 229 | onMouseOver: _propTypes.default.func, 230 | 231 | /** 232 | * [Optional] The mouse up event handler 233 | */ 234 | onMouseUp: _propTypes.default.func 235 | }; 236 | var _default = MarkerLayer; 237 | exports.default = _default; -------------------------------------------------------------------------------- /dist/components/MarkerLayer/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | Object.defineProperty(exports, "MarkerLayer", { 9 | enumerable: true, 10 | get: function () { 11 | return _Component.default; 12 | } 13 | }); 14 | 15 | var _Component = _interopRequireDefault(require("./Component")); -------------------------------------------------------------------------------- /dist/components/ReactMapboxGlCluster/ClusterLayer.css: -------------------------------------------------------------------------------- 1 | .cluster-layer-container { 2 | width: 30px; 3 | height: 30px; 4 | } 5 | 6 | .cluster-layer--cluster { 7 | width: 30px; 8 | height: 30px; 9 | border-radius: 50%; 10 | background-color: rgba(33, 150, 243, 0.8); 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | color: white; 15 | cursor: pointer; 16 | } 17 | 18 | .cluster-layer--cluster:before { 19 | content: " "; 20 | position: absolute; 21 | border-radius: 50%; 22 | width: 40px; 23 | height: 40px; 24 | background-color: rgba(33, 150, 243, 0.6); 25 | z-index: -1; 26 | } 27 | 28 | .cluster-layer--point { 29 | width: 20px; 30 | height: 20px; 31 | border-radius: 50%; 32 | background-color: rgba(195, 38, 94, 0.8); 33 | border: 1px solid #ffffff; 34 | } 35 | 36 | .cluster-layer--point:hover { 37 | width: 25px; 38 | height: 25px; 39 | border-width: 2px !important; 40 | } 41 | 42 | .marker-content.hovered .cluster-layer--cluster { 43 | width: 40px; 44 | height: 40px; 45 | } 46 | 47 | .marker-content.hovered .cluster-layer--cluster:before { 48 | width: 50px; 49 | height: 50px; 50 | } 51 | 52 | .marker-content.hovered .cluster-layer--point { 53 | width: 25px; 54 | height: 25px; 55 | border-color: #51dbd3 !important; 56 | border-width: 2px !important; 57 | } 58 | -------------------------------------------------------------------------------- /dist/components/ReactMapboxGlCluster/ClusterLayer.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | var _interopRequireWildcard = require("@babel/runtime/helpers/interopRequireWildcard").default; 6 | 7 | Object.defineProperty(exports, "__esModule", { 8 | value: true 9 | }); 10 | exports.default = void 0; 11 | 12 | var _react = _interopRequireWildcard(require("react")); 13 | 14 | var _classnames = _interopRequireDefault(require("classnames")); 15 | 16 | var _invariant = require("@turf/invariant"); 17 | 18 | var _reactMapboxGl = require("react-mapbox-gl"); 19 | 20 | var _utils = require("../../common/utils"); 21 | 22 | var _MarkerLayer = require("../MarkerLayer"); 23 | 24 | require("./ClusterLayer.css"); 25 | 26 | class ClusterLayer extends _react.PureComponent { 27 | constructor() { 28 | super(...arguments); 29 | 30 | this._clusterMarkerFactory = (coordinates, pointCount, getLeaves) => { 31 | const { 32 | clusterClassName 33 | } = this.props; 34 | const className = (0, _classnames.default)("cluster-layer--cluster", clusterClassName); 35 | const points = getLeaves(); 36 | 37 | const pointsProps = this._getPointsProps(points); 38 | 39 | const clusterEventHandlers = (0, _utils.extractEventHandlers)(this.props, /^onCluster(.+)$/i); 40 | return /*#__PURE__*/_react.default.createElement(_MarkerLayer.MarkerLayer, Object.assign({ 41 | key: coordinates.toString(), 42 | coordinates: coordinates, 43 | className: "cluster-layer-container", 44 | properties: pointsProps 45 | }, clusterEventHandlers), /*#__PURE__*/_react.default.createElement("div", { 46 | className: className 47 | }, /*#__PURE__*/_react.default.createElement("div", null, pointCount))); 48 | }; 49 | } 50 | 51 | _getClusterProps() { 52 | const { 53 | radius, 54 | minZoom, 55 | maxZoom, 56 | extent, 57 | nodeSize 58 | } = this.props; 59 | return { 60 | radius, 61 | minZoom, 62 | maxZoom, 63 | extent, 64 | nodeSize 65 | }; 66 | } 67 | 68 | _getPointsProps(points) { 69 | return points.map(point => { 70 | const feature = point.props["data-feature"]; 71 | const { 72 | properties 73 | } = feature; 74 | return { ...properties, 75 | coordinates: (0, _invariant.getCoord)(feature) 76 | }; 77 | }); 78 | } 79 | 80 | _renderMarkers() { 81 | const { 82 | data, 83 | pointClassName, 84 | pointStyles = {}, 85 | markerComponent: MarkerComponent 86 | } = this.props; 87 | const markerClassName = (0, _classnames.default)("cluster-layer--point", pointClassName); 88 | return data.features.map((feature, key) => { 89 | const { 90 | geometry: { 91 | coordinates 92 | }, 93 | properties 94 | } = feature; 95 | const { 96 | style 97 | } = properties; 98 | const eventHandlers = (0, _utils.extractEventHandlers)(this.props); 99 | const cssObject = { ...pointStyles, 100 | ...style 101 | }; 102 | return /*#__PURE__*/_react.default.createElement(_MarkerLayer.MarkerLayer, Object.assign({ 103 | key: `cluster-layer-point${key}`, 104 | coordinates: coordinates, 105 | "data-feature": feature, 106 | properties: properties 107 | }, eventHandlers), MarkerComponent ? /*#__PURE__*/_react.default.createElement(MarkerComponent, { 108 | properties: properties, 109 | className: markerClassName, 110 | style: cssObject 111 | }) : /*#__PURE__*/_react.default.createElement("div", { 112 | className: markerClassName, 113 | style: cssObject 114 | })); 115 | }); 116 | } 117 | 118 | render() { 119 | const clusterProps = this._getClusterProps(); 120 | 121 | return /*#__PURE__*/_react.default.createElement(_reactMapboxGl.Cluster, Object.assign({ 122 | ClusterMarkerFactory: this._clusterMarkerFactory 123 | }, clusterProps), this._renderMarkers()); 124 | } 125 | 126 | } 127 | 128 | ClusterLayer.displayName = "ClusterLayer"; 129 | ClusterLayer.defaultProps = { 130 | radius: 60, 131 | minZoom: 0, 132 | maxZoom: 20, 133 | extent: 512, 134 | nodeSize: 64 135 | }; 136 | var _default = ClusterLayer; 137 | exports.default = _default; -------------------------------------------------------------------------------- /dist/components/ReactMapboxGlCluster/MapboxGlCluster.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _hoc = require("../../common/hoc"); 11 | 12 | var _ClusterLayer = _interopRequireDefault(require("./ClusterLayer")); 13 | 14 | const ClusterLayerWithOverlappedPoints = (0, _hoc.detectLocationHasOverlappedPoints)(_ClusterLayer.default); 15 | const ZoomableClusterLayer = (0, _hoc.doZoomingOnClick)(ClusterLayerWithOverlappedPoints); 16 | const MapboxGlCluster = (0, _hoc.connectWithSpiderifierPoint)(ZoomableClusterLayer); 17 | var _default = MapboxGlCluster; 18 | exports.default = _default; -------------------------------------------------------------------------------- /dist/components/ReactMapboxGlCluster/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | exports.default = void 0; 9 | 10 | var _MapboxGlCluster = _interopRequireDefault(require("./MapboxGlCluster")); 11 | 12 | var _default = _MapboxGlCluster.default; 13 | exports.default = _default; -------------------------------------------------------------------------------- /dist/components/ReactMapboxGlCluster/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "cluster-layer", 4 | "main": "./index.js" 5 | } 6 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault").default; 4 | 5 | Object.defineProperty(exports, "__esModule", { 6 | value: true 7 | }); 8 | Object.defineProperty(exports, "ReactMapboxGlCluster", { 9 | enumerable: true, 10 | get: function () { 11 | return _ReactMapboxGlCluster.default; 12 | } 13 | }); 14 | 15 | var _ReactMapboxGlCluster = _interopRequireDefault(require("./components/ReactMapboxGlCluster")); -------------------------------------------------------------------------------- /dist/types/common/constants/ClusterOptions.d.ts: -------------------------------------------------------------------------------- 1 | export namespace ClusterOptions { 2 | let NearestPointsRadius: number; 3 | let ZoomLevel: number; 4 | } 5 | -------------------------------------------------------------------------------- /dist/types/common/constants/GeoJSONTypes.d.ts: -------------------------------------------------------------------------------- 1 | export namespace GeoJSONTypes { 2 | let Point: string; 3 | let MultiPoint: string; 4 | let LineString: string; 5 | let MultiLineString: string; 6 | let Polygon: string; 7 | let MultiPolygon: string; 8 | let GeometryCollection: string; 9 | let FeatureCollection: string; 10 | } 11 | export const NormalTypes: string[]; 12 | export const CollectionTypes: string[]; 13 | export const ListKeysByType: { 14 | [x: string]: string; 15 | }; 16 | -------------------------------------------------------------------------------- /dist/types/common/hoc/connectWithSpiderifierPoint.d.ts: -------------------------------------------------------------------------------- 1 | export default connectWithSpiderifierPoint; 2 | /** 3 | * @type Class 4 | */ 5 | declare const connectWithSpiderifierPoint: Class; 6 | -------------------------------------------------------------------------------- /dist/types/common/hoc/detectLocationHasOverlappedPoints.d.ts: -------------------------------------------------------------------------------- 1 | export default detectLocationHasOverlappedPoints; 2 | /** 3 | * @type Class 4 | */ 5 | declare const detectLocationHasOverlappedPoints: Class; 6 | -------------------------------------------------------------------------------- /dist/types/common/hoc/doZoomingOnClick.d.ts: -------------------------------------------------------------------------------- 1 | export default doZoomingOnClick; 2 | /** 3 | * @type Class 4 | */ 5 | declare const doZoomingOnClick: Class; 6 | -------------------------------------------------------------------------------- /dist/types/common/hoc/index.d.ts: -------------------------------------------------------------------------------- 1 | import connectWithSpiderifierPoint from "./connectWithSpiderifierPoint"; 2 | import detectLocationHasOverlappedPoints from "./detectLocationHasOverlappedPoints"; 3 | import doZoomingOnClick from "./doZoomingOnClick"; 4 | export { connectWithSpiderifierPoint, detectLocationHasOverlappedPoints, doZoomingOnClick }; 5 | -------------------------------------------------------------------------------- /dist/types/common/utils/calc.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculate the next zoom level base on the current zoom 3 | * @param {number} currentZoom the current zoom level 4 | * @param {number} maxZoom the max zoom level of the map 5 | * @param {number} extraZoomLevels how many extra level more for each zoom 6 | */ 7 | export function calculateNextZoomLevel(currentZoom: number, maxZoom?: number, extraZoomLevels?: number): number; 8 | -------------------------------------------------------------------------------- /dist/types/common/utils/cluster.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Find the list of point that inside a specific radius 3 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 4 | * @param {MapBox} mapBox Required. The mapbox instance 5 | * @param {number} zoom The zoom level, at which the points is clustered 6 | * @return {Array} The list of feature 7 | */ 8 | export function createClusters(data: FeatureCollection, mapBox: MapBox, radius: number | undefined, zoom: number): Array; 9 | /** 10 | * Find the list of point that have a similar location (lngLat) 11 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 12 | * @param {Coordinate} lngLat Required. The coordinate follow format [longitude, latitude] 13 | * @param {MapBox} mapBox Required. The mapbox instance 14 | * @param {number} radius The radius of the cluster 15 | * @param {number} zoom The zoom level, at which the points is clustered 16 | * @return {Array} The list of point at the same location. Null if cannot find the 17 | * similar points 18 | */ 19 | export function findPointsWithSameLocation(data: FeatureCollection, lngLat: Coordinate, mapBox: MapBox, radius: number | undefined, zoom: number): Array; 20 | /** 21 | * Group the list of point that inside a specific radius 22 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 23 | * @param {MapBox} mapBox Required. The mapbox instance 24 | * @param {number} radius Optional. The radius of the cluster 25 | * @return {Array>} The list of grouped feature 26 | */ 27 | export function groupNearestPointsByRadius(data: FeatureCollection, mapBox: MapBox, radius?: number): Array>; 28 | -------------------------------------------------------------------------------- /dist/types/common/utils/event.d.ts: -------------------------------------------------------------------------------- 1 | export function extractEventHandlers(props: any, eventPrefix?: RegExp): any; 2 | export function getExactEventHandlerName(event: any): any; 3 | -------------------------------------------------------------------------------- /dist/types/common/utils/index.d.ts: -------------------------------------------------------------------------------- 1 | import { calculateNextZoomLevel } from "./calc"; 2 | import { createClusters } from "./cluster"; 3 | import { checkPropsChange } from "./props"; 4 | import { extractEventHandlers } from "./event"; 5 | import { findPointsWithSameLocation } from "./cluster"; 6 | import { getExactEventHandlerName } from "./event"; 7 | import { groupNearestPointsByRadius } from "./cluster"; 8 | import { isReactComponent } from "./react"; 9 | export { calculateNextZoomLevel, createClusters, checkPropsChange, extractEventHandlers, findPointsWithSameLocation, getExactEventHandlerName, groupNearestPointsByRadius, isReactComponent }; 10 | -------------------------------------------------------------------------------- /dist/types/common/utils/props.d.ts: -------------------------------------------------------------------------------- 1 | export function checkPropsChange(props: any, nextProps: any, keys: any, equalityChecker?: any): any; 2 | -------------------------------------------------------------------------------- /dist/types/common/utils/react.d.ts: -------------------------------------------------------------------------------- 1 | export function isReactComponent(component: any): bool; 2 | -------------------------------------------------------------------------------- /dist/types/components/MappedComponent.d.ts: -------------------------------------------------------------------------------- 1 | export default MappedComponent; 2 | declare class MappedComponent { 3 | static contextType: React.Context; 4 | getMapInstance(): any; 5 | } 6 | -------------------------------------------------------------------------------- /dist/types/components/MarkerLayer/Component.d.ts: -------------------------------------------------------------------------------- 1 | export default MarkerLayer; 2 | declare class MarkerLayer extends MappedComponent { 3 | componentDidMount(): void; 4 | layer: import("mapbox-gl").Marker | undefined; 5 | componentDidUpdate(prevProps: any, prevState: any): void; 6 | componentWillUnmount(): void; 7 | attachChildren(props?: any): HTMLDivElement | undefined; 8 | element: HTMLDivElement | undefined; 9 | getContainerClassName(props: any): string; 10 | getContent(props: any): any; 11 | getProperties(): any; 12 | getOffset(): number[]; 13 | getStyle(props: any): any; 14 | _bindEvents(): void; 15 | realHandlers: {} | undefined; 16 | _disableMapDragPan: () => void; 17 | _enableMapDragPan: () => void; 18 | _generateEventHander: (eventName: any) => (e: any) => void; 19 | _unbindEvents(): void; 20 | render(): null; 21 | } 22 | declare namespace MarkerLayer { 23 | let displayName: string; 24 | namespace propTypes { 25 | let coordinates: any; 26 | let properties: any; 27 | let className: any; 28 | let style: any; 29 | let children: any; 30 | let onClick: any; 31 | let onMouseDown: any; 32 | let onMouseEnter: any; 33 | let onMouseLeave: any; 34 | let onMouseMove: any; 35 | let onMouseOut: any; 36 | let onMouseOver: any; 37 | let onMouseUp: any; 38 | } 39 | } 40 | import MappedComponent from "../MappedComponent"; 41 | -------------------------------------------------------------------------------- /dist/types/components/MarkerLayer/index.d.ts: -------------------------------------------------------------------------------- 1 | export { MarkerLayer }; 2 | import MarkerLayer from "./Component"; 3 | -------------------------------------------------------------------------------- /dist/types/components/ReactMapboxGlCluster/ClusterLayer.d.ts: -------------------------------------------------------------------------------- 1 | export default ClusterLayer; 2 | declare class ClusterLayer { 3 | _clusterMarkerFactory: (coordinates: any, pointCount: any, getLeaves: any) => any; 4 | _getClusterProps(): { 5 | radius: any; 6 | minZoom: any; 7 | maxZoom: any; 8 | extent: any; 9 | nodeSize: any; 10 | }; 11 | _getPointsProps(points: any): any; 12 | _renderMarkers(): any; 13 | render(): any; 14 | } 15 | declare namespace ClusterLayer { 16 | let displayName: string; 17 | namespace propTypes { 18 | let data: any; 19 | let radius: any; 20 | let minZoom: any; 21 | let maxZoom: any; 22 | let extent: any; 23 | let nodeSize: any; 24 | let pointClassName: any; 25 | let pointStyles: any; 26 | let clusterClassName: any; 27 | let markerComponent: any; 28 | let onMouseLeave: any; 29 | let onClick: any; 30 | let onClusterClick: any; 31 | let onClusterMouseEnter: any; 32 | let onClusterMouseLeave: any; 33 | } 34 | namespace defaultProps { 35 | let radius_1: number; 36 | export { radius_1 as radius }; 37 | let minZoom_1: number; 38 | export { minZoom_1 as minZoom }; 39 | let maxZoom_1: number; 40 | export { maxZoom_1 as maxZoom }; 41 | let extent_1: number; 42 | export { extent_1 as extent }; 43 | let nodeSize_1: number; 44 | export { nodeSize_1 as nodeSize }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /dist/types/components/ReactMapboxGlCluster/MapboxGlCluster.d.ts: -------------------------------------------------------------------------------- 1 | export default MapboxGlCluster; 2 | declare const MapboxGlCluster: any; 3 | -------------------------------------------------------------------------------- /dist/types/components/ReactMapboxGlCluster/index.d.ts: -------------------------------------------------------------------------------- 1 | export default MapboxGlCluster; 2 | import MapboxGlCluster from "./MapboxGlCluster"; 3 | -------------------------------------------------------------------------------- /dist/types/index.d.ts: -------------------------------------------------------------------------------- 1 | export { ReactMapboxGlCluster }; 2 | import ReactMapboxGlCluster from "./components/ReactMapboxGlCluster"; 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-mapbox-gl-cluster", 3 | "version": "1.20.0", 4 | "main": "dist/index.js", 5 | "types": "dist/types/index.d.ts", 6 | "module": "dist/index.js", 7 | "files": [ 8 | "dist", 9 | "README.md" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/thuanmb/react-mapbox-gl-cluster" 14 | }, 15 | "dependencies": { 16 | "@babel/runtime": "^7.25.0", 17 | "@turf/invariant": "^7.1.0", 18 | "classnames": "^2.5.1", 19 | "lodash": "^4.17.21", 20 | "mapbox-gl": "^3.6.0", 21 | "prop-types": "^15.7.2", 22 | "react-mapbox-gl": "^5.1.1", 23 | "react-mapbox-gl-spiderifier": "^1.12.0", 24 | "supercluster": "^8.0.1" 25 | }, 26 | "devDependencies": { 27 | "@babel/cli": "^7.24.8", 28 | "@tsconfig/create-react-app": "^2.0.5", 29 | "babel-preset-react-app": "^10.0.0", 30 | "postcss-normalize": "^10.0.1", 31 | "react": "^18.3.1", 32 | "react-dom": "^18.3.1", 33 | "react-scripts": "^5.0.1", 34 | "typescript": "^5.5.4" 35 | }, 36 | "scripts": { 37 | "start": "react-scripts start", 38 | "build": "rm -rf dist && NODE_ENV=production babel src/lib --out-dir dist --copy-files --ignore __tests__,spec.js,test.js,__snapshots__ --presets=./babelPresets.js; npx tsc -p tsconfig.json", 39 | "test": "react-scripts test", 40 | "eject": "react-scripts eject" 41 | }, 42 | "eslintConfig": { 43 | "extends": "react-app" 44 | }, 45 | "browserslist": [ 46 | "defaults", 47 | "not ie 11" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thuanmb/react-mapbox-gl-cluster/c7ba16f3b3726aaf96e6583af57ff6f69a825dd2/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 16 | 17 | 26 | React App 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | height: 100vh; 3 | text-align: center; 4 | } 5 | 6 | .mapboxgl-map { 7 | height: 100%; 8 | } 9 | 10 | .mapboxgl-popup { 11 | z-index: 999; 12 | } 13 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import ReactMapboxGl from "react-mapbox-gl"; 3 | import { ReactMapboxGlCluster } from "./lib"; 4 | import { data } from "./data"; 5 | import "./App.css"; 6 | 7 | const Map = ReactMapboxGl({ 8 | accessToken: process.env.REACT_APP_MAPBOX_GL_TOKEN, 9 | }); 10 | 11 | const mapProps = { 12 | center: [-95.7129, 37.0902], 13 | zoom: [3], 14 | style: "mapbox://styles/mapbox/streets-v8", 15 | }; 16 | 17 | const CustomSpiralComponent = ({ properties, ...restProps }) => { 18 | const onClick = (e) => { 19 | console.log(`Receive event onClick in spiral at properties: ${JSON.stringify(properties)}`); 20 | }; 21 | return
; 22 | }; 23 | 24 | const CustomeMarkerComponent = ({ properties, className, cssObject }) => { 25 | const onClick = (e) => { 26 | console.log(`Receive event onClick in marker at properties: ${JSON.stringify(properties)}`); 27 | }; 28 | return
; 29 | }; 30 | 31 | class App extends Component { 32 | getEventHandlers() { 33 | return { 34 | onClick: (properties, coords, offset) => 35 | console.log(`Receive event onClick at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 36 | onMouseEnter: (properties, coords, offset) => 37 | console.log(`Receive event onMouseEnter at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 38 | onMouseLeave: (properties, coords, offset) => 39 | console.log(`Receive event onMouseLeave at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 40 | onClusterClick: (properties, coords, offset) => 41 | console.log(`Receive event onClusterClick at properties: ${properties}, coords: ${coords}, offset: ${offset}`), 42 | onClusterMouseEnter: (properties, coords, offset) => 43 | console.log( 44 | `Receive event onClusterMouseEnter at properties: ${properties}, coords: ${coords}, offset: ${offset}` 45 | ), 46 | onClusterMouseLeave: (properties, coords, offset) => 47 | console.log( 48 | `Receive event onClusterMouseLeave at properties: ${properties}, coords: ${coords}, offset: ${offset}` 49 | ), 50 | }; 51 | } 52 | 53 | render() { 54 | return ( 55 |
56 | 57 | 64 | 65 |
66 | ); 67 | } 68 | } 69 | 70 | export default App; 71 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import App from "./App"; 4 | 5 | it("renders without crashing", () => { 6 | const div = document.createElement("div"); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/data.js: -------------------------------------------------------------------------------- 1 | export const data = { 2 | type: "FeatureCollection", 3 | features: [ 4 | { 5 | type: "Feature", 6 | properties: {}, 7 | geometry: { 8 | type: "Point", 9 | coordinates: [-120.05859375, 45.644768217751924] 10 | } 11 | }, 12 | { 13 | type: "Feature", 14 | properties: {}, 15 | geometry: { 16 | type: "Point", 17 | coordinates: [-117.0703125, 46.01222384063236] 18 | } 19 | }, 20 | { 21 | type: "Feature", 22 | properties: {}, 23 | geometry: { 24 | type: "Point", 25 | coordinates: [-110.12695312499999, 45.767522962149876] 26 | } 27 | }, 28 | { 29 | type: "Feature", 30 | properties: {}, 31 | geometry: { 32 | type: "Point", 33 | coordinates: [-101.6015625, 44.96479793033101] 34 | } 35 | }, 36 | { 37 | type: "Feature", 38 | properties: {}, 39 | geometry: { 40 | type: "Point", 41 | coordinates: [-92.63671875, 40.97989806962013] 42 | } 43 | }, 44 | { 45 | type: "Feature", 46 | properties: {}, 47 | geometry: { 48 | type: "Point", 49 | coordinates: [-90.615234375, 37.3002752813443] 50 | } 51 | }, 52 | { 53 | type: "Feature", 54 | properties: {}, 55 | geometry: { 56 | type: "Point", 57 | coordinates: [-116.89453125, 40.245991504199026] 58 | } 59 | }, 60 | { 61 | type: "Feature", 62 | properties: {}, 63 | geometry: { 64 | type: "Point", 65 | coordinates: [-108.28125, 34.74161249883172] 66 | } 67 | }, 68 | { 69 | type: "Feature", 70 | properties: {}, 71 | geometry: { 72 | type: "Point", 73 | coordinates: [-92.373046875, 33.063924198120645] 74 | } 75 | }, 76 | { 77 | type: "Feature", 78 | properties: {}, 79 | geometry: { 80 | type: "Point", 81 | coordinates: [-83.583984375, 39.36827914916014] 82 | } 83 | }, 84 | { 85 | type: "Feature", 86 | properties: {}, 87 | geometry: { 88 | type: "Point", 89 | coordinates: [-102.3046875, 38.13455657705411] 90 | } 91 | }, 92 | { 93 | type: "Feature", 94 | properties: {}, 95 | geometry: { 96 | type: "Point", 97 | coordinates: [-100.986328125, 32.175612478499325] 98 | } 99 | }, 100 | { 101 | type: "Feature", 102 | properties: {}, 103 | geometry: { 104 | type: "Point", 105 | coordinates: [-82.880859375, 34.66935854524543] 106 | } 107 | }, 108 | { 109 | type: "Feature", 110 | properties: {}, 111 | geometry: { 112 | type: "Point", 113 | coordinates: [-98.61328125, 42.94033923363181] 114 | } 115 | }, 116 | { 117 | type: "Feature", 118 | properties: {}, 119 | geometry: { 120 | type: "Point", 121 | coordinates: [-107.490234375, 43.32517767999296] 122 | } 123 | }, 124 | { 125 | type: "Feature", 126 | properties: {}, 127 | geometry: { 128 | type: "Point", 129 | coordinates: [-92.43896484375, 40.66397287638688] 130 | } 131 | }, 132 | { 133 | type: "Feature", 134 | properties: {}, 135 | geometry: { 136 | type: "Point", 137 | coordinates: [-93.88916015625, 40.329795743702064] 138 | } 139 | }, 140 | { 141 | type: "Feature", 142 | properties: {}, 143 | geometry: { 144 | type: "Point", 145 | coordinates: [-97.0751953125, 40.43022363450862] 146 | } 147 | }, 148 | { 149 | type: "Feature", 150 | properties: {}, 151 | geometry: { 152 | type: "Point", 153 | coordinates: [-92.04345703125, 39.926588421909436] 154 | } 155 | }, 156 | { 157 | type: "Feature", 158 | properties: {}, 159 | geometry: { 160 | type: "Point", 161 | coordinates: [-108.017578125, 42.032974332441405] 162 | } 163 | }, 164 | { 165 | type: "Feature", 166 | properties: {}, 167 | geometry: { 168 | type: "Point", 169 | coordinates: [-88.59374999999999, 42.8115217450979] 170 | } 171 | }, 172 | { 173 | type: "Feature", 174 | properties: {}, 175 | geometry: { 176 | type: "Point", 177 | coordinates: [-85.78125, 39.095962936305476] 178 | } 179 | }, 180 | { 181 | type: "Feature", 182 | properties: {}, 183 | geometry: { 184 | type: "Point", 185 | coordinates: [-114.521484375, 36.03133177633187] 186 | } 187 | }, 188 | { 189 | type: "Feature", 190 | properties: {}, 191 | geometry: { 192 | type: "Point", 193 | coordinates: [-111.796875, 40.91351257612758] 194 | } 195 | }, 196 | { 197 | type: "Feature", 198 | properties: {}, 199 | geometry: { 200 | type: "Point", 201 | coordinates: [-105.46875, 47.81315451752768] 202 | } 203 | }, 204 | { 205 | type: "Feature", 206 | properties: {}, 207 | geometry: { 208 | type: "Point", 209 | coordinates: [-94.130859375, 45.706179285330855] 210 | } 211 | }, 212 | { 213 | type: "Feature", 214 | properties: {}, 215 | geometry: { 216 | type: "Point", 217 | coordinates: [-120.84960937499999, 38.41055825094609] 218 | } 219 | }, 220 | { 221 | type: "Feature", 222 | properties: {}, 223 | geometry: { 224 | type: "Point", 225 | coordinates: [-120.76171875, 42.94033923363181] 226 | } 227 | }, 228 | { 229 | type: "Feature", 230 | properties: {}, 231 | geometry: { 232 | type: "Point", 233 | coordinates: [-114.08203125, 44.902577996288876] 234 | } 235 | }, 236 | { 237 | type: "Feature", 238 | properties: {}, 239 | geometry: { 240 | type: "Point", 241 | coordinates: [-100.986328125, 32.24997445586331] 242 | } 243 | }, 244 | { 245 | type: "Feature", 246 | properties: {}, 247 | geometry: { 248 | type: "Point", 249 | coordinates: [-98.45947265625, 35.44277092585766] 250 | } 251 | }, 252 | { 253 | type: "Feature", 254 | properties: {}, 255 | geometry: { 256 | type: "Point", 257 | coordinates: [-96.43798828125, 37.020098201368114] 258 | } 259 | }, 260 | { 261 | type: "Feature", 262 | properties: {}, 263 | geometry: { 264 | type: "Point", 265 | coordinates: [-92.52685546875, 36.98500309285596] 266 | } 267 | }, 268 | { 269 | type: "Feature", 270 | properties: {}, 271 | geometry: { 272 | type: "Point", 273 | coordinates: [-75.4541015625, 41.65649719441145] 274 | } 275 | }, 276 | { 277 | type: "Feature", 278 | properties: {}, 279 | geometry: { 280 | type: "Point", 281 | coordinates: [-76.57470703125, 41.261291493919884] 282 | } 283 | }, 284 | { 285 | type: "Feature", 286 | properties: {}, 287 | geometry: { 288 | type: "Point", 289 | coordinates: [-76.70654296875, 40.84706035607122] 290 | } 291 | }, 292 | { 293 | type: "Feature", 294 | properties: {}, 295 | geometry: { 296 | type: "Point", 297 | coordinates: [-76.2890625, 40.463666324587685] 298 | } 299 | }, 300 | { 301 | type: "Feature", 302 | properties: {}, 303 | geometry: { 304 | type: "Point", 305 | coordinates: [-74.37744140625, 41.29431726315258] 306 | } 307 | }, 308 | { 309 | type: "Feature", 310 | properties: {}, 311 | geometry: { 312 | type: "Point", 313 | coordinates: [-74.68505859374999, 40.697299008636755] 314 | } 315 | }, 316 | { 317 | type: "Feature", 318 | properties: {}, 319 | geometry: { 320 | type: "Point", 321 | coordinates: [-78.20068359374999, 40.3130432088809] 322 | } 323 | }, 324 | { 325 | type: "Feature", 326 | properties: {}, 327 | geometry: { 328 | type: "Point", 329 | coordinates: [-77.47558593749999, 39.52099229357195] 330 | } 331 | }, 332 | { 333 | type: "Feature", 334 | properties: {}, 335 | geometry: { 336 | type: "Point", 337 | coordinates: [-78.06884765624999, 41.83682786072714] 338 | } 339 | }, 340 | { 341 | type: "Feature", 342 | properties: {}, 343 | geometry: { 344 | type: "Point", 345 | coordinates: [-74.11376953125, 42.76314586689492] 346 | } 347 | }, 348 | { 349 | type: "Feature", 350 | properties: {}, 351 | geometry: { 352 | type: "Point", 353 | coordinates: [-75.41015624999999, 42.68243539838623] 354 | } 355 | }, 356 | { 357 | type: "Feature", 358 | properties: {}, 359 | geometry: { 360 | type: "Point", 361 | coordinates: [-85.8251953125, 36.61552763134925] 362 | } 363 | }, 364 | { 365 | type: "Feature", 366 | properties: {}, 367 | geometry: { 368 | type: "Point", 369 | coordinates: [-75.03662109375, 39.470125122358176] 370 | } 371 | }, 372 | { 373 | type: "Feature", 374 | properties: {}, 375 | geometry: { 376 | type: "Point", 377 | coordinates: [-73.93798828125, 40.713955826286046] 378 | } 379 | }, 380 | { 381 | type: "Feature", 382 | properties: {}, 383 | geometry: { 384 | type: "Point", 385 | coordinates: [-72.7734375, 43.5326204268101] 386 | } 387 | }, 388 | { 389 | type: "Feature", 390 | properties: {}, 391 | geometry: { 392 | type: "Point", 393 | coordinates: [-81.5185546875, 43.068887774169625] 394 | } 395 | }, 396 | { 397 | type: "Feature", 398 | properties: {}, 399 | geometry: { 400 | type: "Point", 401 | coordinates: [-74.1357421875, 40.56389453066509] 402 | } 403 | }, 404 | { 405 | type: "Feature", 406 | properties: {}, 407 | geometry: { 408 | type: "Point", 409 | coordinates: [-74.1357421875, 40.56389453066509] 410 | } 411 | }, 412 | { 413 | type: "Feature", 414 | properties: {}, 415 | geometry: { 416 | type: "Point", 417 | coordinates: [-74.1357421875, 40.56389453066509] 418 | } 419 | }, 420 | { 421 | type: "Feature", 422 | properties: {}, 423 | geometry: { 424 | type: "Point", 425 | coordinates: [-74.1357421875, 40.56389453066509] 426 | } 427 | }, 428 | { 429 | type: "Feature", 430 | properties: {}, 431 | geometry: { 432 | type: "Point", 433 | coordinates: [-74.1357421875, 40.56389453066509] 434 | } 435 | } 436 | ] 437 | }; 438 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./index.css"; 3 | import App from "./App"; 4 | import * as ReactDOMClient from 'react-dom/client'; 5 | import * as serviceWorker from "./serviceWorker"; 6 | 7 | const container = document.getElementById("root"); 8 | const root = ReactDOMClient.createRoot(container); 9 | root.render(); 10 | 11 | // If you want your app to work offline and load faster, you can change 12 | // unregister() to register() below. Note this comes with some pitfalls. 13 | // Learn more about service workers: https://bit.ly/CRA-PWA 14 | serviceWorker.unregister(); 15 | -------------------------------------------------------------------------------- /src/lib/common/constants/ClusterOptions.js: -------------------------------------------------------------------------------- 1 | const ClusterOptions = { 2 | NearestPointsRadius: 7, 3 | ZoomLevel: 17 4 | }; 5 | 6 | export { ClusterOptions }; 7 | -------------------------------------------------------------------------------- /src/lib/common/constants/GeoJSONTypes.js: -------------------------------------------------------------------------------- 1 | const GeoJSONTypes = { 2 | Point: "Point", 3 | MultiPoint: "MultiPoint", 4 | LineString: "LineString", 5 | MultiLineString: "MultiLineString", 6 | Polygon: "Polygon", 7 | MultiPolygon: "MultiPolygon", 8 | GeometryCollection: "GeometryCollection", 9 | FeatureCollection: "FeatureCollection" 10 | }; 11 | 12 | const NormalTypes = [ 13 | GeoJSONTypes.Point, 14 | GeoJSONTypes.MultiPoint, 15 | GeoJSONTypes.LineString, 16 | GeoJSONTypes.MultiLineString, 17 | GeoJSONTypes.Polygon, 18 | GeoJSONTypes.MultiPolygon 19 | ]; 20 | 21 | const CollectionTypes = [GeoJSONTypes.GeometryCollection, GeoJSONTypes.FeatureCollection]; 22 | 23 | const ListKeysByType = { 24 | [GeoJSONTypes.GeometryCollection]: "geometries", 25 | [GeoJSONTypes.FeatureCollection]: "features" 26 | }; 27 | 28 | export { GeoJSONTypes, NormalTypes, CollectionTypes, ListKeysByType }; 29 | -------------------------------------------------------------------------------- /src/lib/common/hoc/connectWithSpiderifierPoint.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import _ from "lodash"; 4 | import { checkPropsChange } from "../utils"; 5 | import { ReactMapboxGlSpiderifier } from "react-mapbox-gl-spiderifier"; 6 | import { getCoord } from "@turf/invariant"; 7 | import { findPointsWithSameLocation, groupNearestPointsByRadius } from "../utils"; 8 | import { ClusterOptions } from "../constants/ClusterOptions"; 9 | import MappedComponent from "../../components/MappedComponent"; 10 | import "./spiderifier.css"; 11 | 12 | const SPIDERIFIER_PROPS = [ 13 | "coordinates", 14 | "circleSpiralSwitchover", 15 | "circleFootSeparation", 16 | "spiralFootSeparation", 17 | "spiralLengthStart", 18 | "spiralLengthFactor", 19 | "animate", 20 | "animationSpeed", 21 | "transformSpiderLeft", 22 | "transformSpiderTop", 23 | "showingLegs", 24 | "onClick", 25 | "onMouseDown", 26 | "onMouseEnter", 27 | "onMouseLeave", 28 | "onMouseMove", 29 | "onMouseOut", 30 | "onMouseOver", 31 | "onMouseUp", 32 | ]; 33 | const MARKER_PROPS = [ 34 | "data", 35 | "radius", 36 | "minZoom", 37 | "maxZoom", 38 | "extent", 39 | "nodeSize", 40 | "pointClassName", 41 | "pointStyles", 42 | "clusterClassName", 43 | "clusterClassName", 44 | "markerComponent", 45 | "onMouseLeave", 46 | "onClick", 47 | "onClusterClick", 48 | "onClusterMouseEnter", 49 | "onClusterMouseLeave", 50 | "clusterClickEnabled", 51 | ]; 52 | 53 | /** 54 | * @type Class 55 | */ 56 | const connectWithSpiderifierPoint = (WrappedComponent) => { 57 | class ConnectedWithSpiderifierComponent extends MappedComponent { 58 | constructor(props) { 59 | super(props); 60 | this.state = { 61 | overlappedPointsGroup: null, 62 | }; 63 | this.registeredEvents = false; 64 | } 65 | 66 | componentDidUpdate(prevProps) { 67 | this._checkAndUpdatePoints(prevProps); 68 | this.bindEvents(); 69 | } 70 | 71 | shouldComponentUpdate(nextProps, nextState) { 72 | return ( 73 | checkPropsChange( 74 | this.props, 75 | nextProps, 76 | [ 77 | "data", 78 | "showInitialSpiderifier", 79 | "onlySpiderifier", 80 | "circleFootSeparation", 81 | "transformSpiderLeft", 82 | "showingLegs", 83 | ], 84 | _.isEqual 85 | ) || !_.isEqual(this.state, nextState) 86 | ); 87 | } 88 | 89 | componentWillUnmount() { 90 | this.unbindEvents(); 91 | } 92 | 93 | bindEvents() { 94 | const map = this.getMapInstance(); 95 | if (map && !this.registeredEvents) { 96 | map.on("zoomend", this.onMapChange); 97 | this.registeredEvents = true; 98 | } 99 | } 100 | unbindEvents() { 101 | const map = this.getMapInstance(); 102 | if (map) { 103 | map.off("zoomend", this.onMapChange); 104 | } 105 | } 106 | 107 | onClickOverlappedPoints = (points, coordinates) => { 108 | this._updateSpiderifierProps([points], coordinates); 109 | }; 110 | 111 | onSpiderifierRemoved(lngLat) { 112 | const { overlappedPointsGroup } = this.state; 113 | if (_.isArray(overlappedPointsGroup)) { 114 | const removedIndex = overlappedPointsGroup.findIndex(({ coordinates }) => _.isEqual(coordinates, lngLat)); 115 | 116 | if (removedIndex > -1) { 117 | const newGroup = [ 118 | ...overlappedPointsGroup.slice(0, removedIndex), 119 | ...overlappedPointsGroup.slice(removedIndex + 1), 120 | ]; 121 | this.setState({ overlappedPointsGroup: newGroup }); 122 | } 123 | } 124 | 125 | const { onSpiderifierRemoved } = this.props; 126 | if (_.isFunction(onSpiderifierRemoved)) { 127 | onSpiderifierRemoved(lngLat); 128 | } 129 | } 130 | 131 | onMapChange = () => { 132 | const { onlySpiderifier } = this.props; 133 | if (!onlySpiderifier && _.isArray(this._spiderifieredLocations)) { 134 | const { data, radius } = this.props; 135 | const map = this.getMapInstance(); 136 | this._spiderifieredLocations.forEach((lngLat) => { 137 | const points = findPointsWithSameLocation(data, lngLat, map, radius); 138 | 139 | if (!points) { 140 | this.onSpiderifierRemoved(lngLat); 141 | } 142 | }); 143 | } 144 | }; 145 | 146 | _checkAndUpdatePoints(prevProps) { 147 | if (checkPropsChange(this.props, prevProps, ["data", "showInitialSpiderifier", "onlySpiderifier"], _.isEqual)) { 148 | this._updatePoints(); 149 | } 150 | } 151 | 152 | _getComponentProps(keys) { 153 | return _.pick(this.props, keys); 154 | } 155 | 156 | _getWrappedComponentProps() { 157 | return this._getComponentProps(MARKER_PROPS); 158 | } 159 | 160 | _getSpiderifierComponentProps() { 161 | return this._getComponentProps(SPIDERIFIER_PROPS); 162 | } 163 | 164 | _groupNearestPoint(props) { 165 | const { data, showInitialSpiderifier, onlySpiderifier } = props; 166 | const map = this.getMapInstance(); 167 | const groupedPoints = groupNearestPointsByRadius(data, map, ClusterOptions.NearestPointsRadius); 168 | 169 | if (groupedPoints.length > 0) { 170 | if (onlySpiderifier && groupedPoints.length === 1) { 171 | this._updateSpiderifierProps(groupedPoints); 172 | } else if (showInitialSpiderifier) { 173 | let firstGroup = groupedPoints.find((group) => group.length > 1); 174 | 175 | if (firstGroup == null) { 176 | firstGroup = groupedPoints[0]; 177 | } 178 | 179 | this._updateSpiderifierProps([firstGroup]); 180 | } 181 | } 182 | } 183 | 184 | _processSpiderifyProperties(props) { 185 | const { spiderifyPropsProcessor } = this.props; 186 | if (_.isFunction(spiderifyPropsProcessor)) { 187 | return spiderifyPropsProcessor(props); 188 | } 189 | 190 | return props; 191 | } 192 | 193 | _renderSpiderifierContent(key, properties) { 194 | const { spiralComponent: SpiralComponent } = this.props; 195 | if (SpiralComponent) { 196 | return ; 197 | } 198 | return ( 199 |
200 |
{properties.label}
201 |
202 | ); 203 | } 204 | 205 | _renderSpiderifier() { 206 | const { overlappedPointsGroup } = this.state; 207 | 208 | if (overlappedPointsGroup && overlappedPointsGroup.length > 0) { 209 | const spiderifierComponentProps = this._getSpiderifierComponentProps(); 210 | 211 | return overlappedPointsGroup.map((overlappedPoints, index) => { 212 | const { coordinates, markers } = overlappedPoints; 213 | 214 | return ( 215 | 216 | {markers.map((marker, index) => this._renderSpiderifierContent(index, marker))} 217 | 218 | ); 219 | }); 220 | } 221 | 222 | return null; 223 | } 224 | 225 | _shouldRenderClusterLayer() { 226 | const { onlySpiderifier, overlappedPointsGroup } = this.props; 227 | return !onlySpiderifier || !overlappedPointsGroup || overlappedPointsGroup.length > 1; 228 | } 229 | 230 | _updatePoints(props = this.props) { 231 | const { data, showInitialSpiderifier, onlySpiderifier } = props; 232 | 233 | if (data != null && (showInitialSpiderifier || onlySpiderifier)) { 234 | this._groupNearestPoint(props); 235 | } 236 | } 237 | 238 | _updateSpiderifierProps(group, coordinates) { 239 | this._spiderifieredLocations = []; 240 | if (group.length > 0) { 241 | const overlappedPointsGroup = group.map((points) => { 242 | if (points.length > 0) { 243 | const properties = points.map((feature) => feature.properties); 244 | let coords = coordinates; 245 | 246 | if (coords == null) { 247 | coords = getCoord(points[0]); 248 | } 249 | return { 250 | markers: this._processSpiderifyProperties(properties), 251 | coordinates: coords, 252 | }; 253 | } 254 | 255 | return null; 256 | }); 257 | 258 | const { onShowSpiderifier } = this.props; 259 | overlappedPointsGroup.forEach((group) => { 260 | const { coordinates, markers } = group; 261 | 262 | this._spiderifieredLocations.push(coordinates); 263 | if (_.isFunction(onShowSpiderifier)) { 264 | onShowSpiderifier(coordinates, markers); 265 | } 266 | }); 267 | 268 | this.setState({ 269 | overlappedPointsGroup, 270 | }); 271 | } 272 | } 273 | 274 | render() { 275 | const wrappedComponentProps = this._getWrappedComponentProps(); 276 | 277 | return ( 278 |
279 | {this._shouldRenderClusterLayer() && ( 280 | 281 | )} 282 | {this._renderSpiderifier()} 283 |
284 | ); 285 | } 286 | } 287 | 288 | ConnectedWithSpiderifierComponent.propTypes = { 289 | /** 290 | * Indicate if the spiderifier should be shown for the first overlapped point onload 291 | */ 292 | showInitialSpiderifier: PropTypes.bool, 293 | 294 | /** 295 | * Indicate if the spiderifier should be shown without wrapped component 296 | */ 297 | onlySpiderifier: PropTypes.bool, 298 | 299 | /** 300 | * Handler to transform the properties of each point 301 | */ 302 | spiderifyPropsProcessor: PropTypes.func, 303 | 304 | /** 305 | * Callback when a spiderifier shown 306 | */ 307 | onShowSpiderifier: PropTypes.func, 308 | 309 | /** 310 | * [Optional] Handle when user do zoom/move to change the map and made the points 311 | * on the map changed and don't have overlapped points anymore 312 | */ 313 | onSpiderifierRemoved: PropTypes.func, 314 | 315 | /** 316 | * Allow to customize the spiral component 317 | */ 318 | spiralComponent: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), 319 | }; 320 | 321 | ConnectedWithSpiderifierComponent.defaultProps = { 322 | ...WrappedComponent.defaultProps, 323 | ...ReactMapboxGlSpiderifier.defaultProps, 324 | }; 325 | 326 | return ConnectedWithSpiderifierComponent; 327 | }; 328 | 329 | export default connectWithSpiderifierPoint; 330 | -------------------------------------------------------------------------------- /src/lib/common/hoc/detectLocationHasOverlappedPoints.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import _ from "lodash"; 4 | import { featureCollection as createFeatureCollection, point as createPoint } from "@turf/helpers"; 5 | import { findPointsWithSameLocation } from "../utils"; 6 | import { ClusterOptions } from "../constants/ClusterOptions"; 7 | import MappedComponent from "../../components/MappedComponent"; 8 | 9 | /** 10 | * @type Class 11 | */ 12 | const detectLocationHasOverlappedPoints = WrappedComponent => { 13 | class LayerWithOverlappedPointComponent extends MappedComponent { 14 | onClick = (properties, lngLat, event, meta) => { 15 | const { onClick } = this.props; 16 | this._handleClick(properties, lngLat, event, meta, onClick); 17 | }; 18 | 19 | onClusterClick = (properties, lngLat, event, meta) => { 20 | const { onClusterClick } = this.props; 21 | this._handleClick(properties, lngLat, event, meta, onClusterClick); 22 | }; 23 | 24 | _handleClick(properties, lngLat, event, meta, callback) { 25 | if (!_.isArray(properties)) { 26 | if (_.isFunction(callback)) { 27 | callback(properties, lngLat, event, meta); 28 | } 29 | 30 | return true; 31 | } 32 | const { onClickOverlappedPoints } = this.props; 33 | const map = this.getMapInstance(); 34 | const features = properties.map(prop => createPoint(prop.coordinates, prop)); 35 | const data = createFeatureCollection(features); 36 | const points = findPointsWithSameLocation( 37 | data, 38 | lngLat, 39 | map, 40 | ClusterOptions.NearestPointsRadius, 41 | ClusterOptions.ZoomLevel 42 | ); 43 | if (points) { 44 | if (_.isFunction(onClickOverlappedPoints)) { 45 | onClickOverlappedPoints(features, lngLat, event, meta); 46 | return false; 47 | } 48 | } else if (_.isFunction(callback)) { 49 | callback(properties, lngLat, event, meta); 50 | } 51 | 52 | return true; 53 | } 54 | 55 | render() { 56 | const props = { 57 | ...this.props, 58 | onClick: this.onClick, 59 | onClusterClick: this.onClusterClick 60 | }; 61 | 62 | return ; 63 | } 64 | } 65 | 66 | LayerWithOverlappedPointComponent.propTypes = { 67 | /** 68 | * [Optional] Handle when user click on a location which has overlapped points 69 | */ 70 | onClickOverlappedPoints: PropTypes.func 71 | }; 72 | 73 | LayerWithOverlappedPointComponent.defaultProps = { 74 | ...WrappedComponent.defaultProps 75 | }; 76 | 77 | return LayerWithOverlappedPointComponent; 78 | }; 79 | 80 | export default detectLocationHasOverlappedPoints; 81 | -------------------------------------------------------------------------------- /src/lib/common/hoc/doZoomingOnClick.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import _ from "lodash"; 4 | import MappedComponent from "../../components/MappedComponent"; 5 | import { calculateNextZoomLevel } from "../utils"; 6 | 7 | /** 8 | * @type Class 9 | */ 10 | const doZoomingOnClick = (WrappedComponent) => { 11 | class ZoomableComponent extends MappedComponent { 12 | onClusterClick = (properties, lngLat, event, meta) => { 13 | const { onClusterClick, clusterClickEnabled } = this.props; 14 | 15 | if (!clusterClickEnabled) { 16 | return; 17 | } 18 | 19 | const map = this.getMapInstance(); 20 | const currentZoom = map.getZoom(); 21 | const maxZoom = map.getMaxZoom(); 22 | const zoom = calculateNextZoomLevel(currentZoom, maxZoom); 23 | 24 | map.flyTo({ center: lngLat, zoom }); 25 | 26 | this._handleClick(properties, lngLat, event, meta, onClusterClick); 27 | }; 28 | 29 | _handleClick(properties, lngLat, event, meta, callback) { 30 | if (_.isFunction(callback)) { 31 | callback(properties, lngLat, event, meta); 32 | } 33 | } 34 | 35 | render() { 36 | const props = { 37 | ...this.props, 38 | onClusterClick: this.onClusterClick, 39 | }; 40 | 41 | return ; 42 | } 43 | } 44 | 45 | ZoomableComponent.propTypes = { 46 | clusterClickEnabled: PropTypes.bool, 47 | }; 48 | 49 | ZoomableComponent.defaultProps = { 50 | ...WrappedComponent.defaultProps, 51 | clusterClickEnabled: true, 52 | }; 53 | 54 | return ZoomableComponent; 55 | }; 56 | 57 | export default doZoomingOnClick; 58 | -------------------------------------------------------------------------------- /src/lib/common/hoc/index.js: -------------------------------------------------------------------------------- 1 | import connectWithSpiderifierPoint from "./connectWithSpiderifierPoint"; 2 | import detectLocationHasOverlappedPoints from "./detectLocationHasOverlappedPoints"; 3 | import doZoomingOnClick from "./doZoomingOnClick"; 4 | 5 | export { connectWithSpiderifierPoint, detectLocationHasOverlappedPoints, doZoomingOnClick }; 6 | -------------------------------------------------------------------------------- /src/lib/common/hoc/spiderifier.css: -------------------------------------------------------------------------------- 1 | .spiderifier-marker-content { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 28px; 6 | height: 28px; 7 | color: rgb(255, 255, 255); 8 | background-color: rgb(195, 38, 94); 9 | border-color: rgb(255, 255, 255); 10 | border-width: 1px; 11 | border-radius: 50%; 12 | margin-left: -14px; 13 | margin-top: -14px; 14 | box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.29); 15 | font-size: 8px; 16 | font-weight: bold; 17 | text-align: center; 18 | border: 1px solid; 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/common/utils/calc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Calculate the next zoom level base on the current zoom 3 | * @param {number} currentZoom the current zoom level 4 | * @param {number} maxZoom the max zoom level of the map 5 | * @param {number} extraZoomLevels how many extra level more for each zoom 6 | */ 7 | const calculateNextZoomLevel = (currentZoom, maxZoom = 20, extraZoomLevels = 2) => { 8 | if (currentZoom >= 14 && currentZoom < maxZoom - 2) { 9 | return maxZoom - 2; 10 | } 11 | 12 | if (currentZoom >= maxZoom - 2) { 13 | return maxZoom; 14 | } 15 | 16 | const delta = maxZoom - currentZoom; 17 | const percentage = delta / maxZoom; 18 | const zoom = 19 | currentZoom + 20 | extraZoomLevels * percentage + 21 | extraZoomLevels * Math.pow(percentage, 2) + 22 | extraZoomLevels * Math.pow(percentage, 3); 23 | 24 | return zoom; 25 | }; 26 | 27 | export { calculateNextZoomLevel }; 28 | -------------------------------------------------------------------------------- /src/lib/common/utils/cluster.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | import Supercluster from "supercluster"; 3 | import { getCoord } from "@turf/invariant"; 4 | import { LngLatBounds } from "mapbox-gl"; 5 | import { GeoJSONTypes, CollectionTypes, ListKeysByType } from "../constants/GeoJSONTypes"; 6 | 7 | const RADIUS_TO_EXTENDS = 200; 8 | 9 | const checkCollectionGeoJSON = data => CollectionTypes.indexOf(data.type) !== -1; 10 | 11 | const createBoundsFromCoordinates = (coordinates, bounds) => { 12 | if (bounds == null) { 13 | return new LngLatBounds(coordinates, coordinates); 14 | } 15 | 16 | return bounds.extend(coordinates); 17 | }; 18 | 19 | const extendBounds = (boundary, radius = 100) => { 20 | const boundObj = new LngLatBounds(boundary); 21 | const ne = boundObj.getNorthEast(); 22 | const neBound = ne.toBounds(radius / 2); 23 | const sw = boundObj.getSouthWest(); 24 | const swBound = sw.toBounds(radius / 2); 25 | return _.flatten([swBound.getSouthWest().toArray(), neBound.getNorthEast().toArray()]); 26 | }; 27 | 28 | const flattenCoordinates = (coordinates, positionType) => { 29 | let depth; 30 | 31 | switch (positionType) { 32 | case GeoJSONTypes.MultiPoint: 33 | case GeoJSONTypes.LineString: 34 | depth = 0; 35 | break; 36 | case GeoJSONTypes.Polygon: 37 | case GeoJSONTypes.MultiLineString: 38 | depth = 1; 39 | break; 40 | case GeoJSONTypes.MultiPolygon: 41 | depth = 2; 42 | break; 43 | case GeoJSONTypes.Point: 44 | default: 45 | depth = -1; 46 | } 47 | 48 | if (depth === -1) { 49 | return [coordinates]; 50 | } 51 | 52 | return _.flattenDepth(coordinates, depth); 53 | }; 54 | 55 | const getCoordinateForPosition = (position, geoJSONType = GeoJSONTypes.FeatureCollection) => { 56 | if (geoJSONType === GeoJSONTypes.FeatureCollection) { 57 | return position.geometry.coordinates; 58 | } 59 | 60 | return position.coordinates; 61 | }; 62 | 63 | const getFeatureList = geoJSON => { 64 | const { type } = geoJSON; 65 | const key = ListKeysByType[type]; 66 | 67 | return geoJSON[key]; 68 | }; 69 | 70 | const getTypeForPosition = (position, geoJSONType) => { 71 | if (geoJSONType === GeoJSONTypes.FeatureCollection) { 72 | return position.geometry.type; 73 | } 74 | 75 | return position.type; 76 | }; 77 | 78 | const roundCoords = coords => [_.round(coords[0], 4), _.round(coords[1], 4)]; 79 | 80 | /** 81 | * Calculate the boundary of a geojson 82 | * @param {object} data a geojson in any format 83 | * @param? {*} totalBounds [Optional] if given, the boundary will be calculated base on the current "totalBounds" 84 | * @return {LngLatBounds} the total boundary 85 | */ 86 | const calculateBoundary = (data, totalBounds = null) => { 87 | const { type } = data; 88 | 89 | if (checkCollectionGeoJSON(data)) { 90 | const features = getFeatureList(data); 91 | features.forEach(feature => { 92 | let coordinates = getCoordinateForPosition(feature, type); 93 | let featureType = getTypeForPosition(feature, type); 94 | coordinates = flattenCoordinates(coordinates, featureType); 95 | 96 | if (!_.isArray(coordinates)) { 97 | return totalBounds; 98 | } 99 | 100 | if (!totalBounds) { 101 | totalBounds = new LngLatBounds(coordinates[0], coordinates[0]); 102 | } 103 | 104 | totalBounds = coordinates.reduce(function(bounds, coord) { 105 | return bounds.extend(coord); 106 | }, totalBounds); 107 | }); 108 | 109 | return totalBounds; 110 | } 111 | 112 | const coordinates = getCoord(data); 113 | return createBoundsFromCoordinates(coordinates, totalBounds); 114 | }; 115 | 116 | /** 117 | * Find the list of point that inside a specific radius 118 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 119 | * @param {MapBox} mapBox Required. The mapbox instance 120 | * @param {number} zoom The zoom level, at which the points is clustered 121 | * @return {Array} The list of feature 122 | */ 123 | const createClusters = (data, mapBox, radius = 60, zoom) => { 124 | if (!data || !data.features || !_.isArray(data.features)) { 125 | throw new Error("Data cannot be empty"); 126 | } 127 | 128 | if (!mapBox) { 129 | throw new Error("Mapbox instance must be provided"); 130 | } 131 | 132 | const superC = new Supercluster({ 133 | radius, 134 | maxZoom: mapBox.getMaxZoom() 135 | }); 136 | 137 | const featureList = getFeatureList(data); 138 | superC.load(featureList); 139 | if (!zoom) { 140 | zoom = mapBox.getZoom(); 141 | } 142 | let boundary = _.isEmpty(featureList) ? [0, 0, 0, 0] : _.flatten(calculateBoundary(data).toArray()); 143 | // in case of all points at the same location, 144 | // extends its coords by 200 meters radius to make superC work. 145 | boundary = extendBounds(boundary, RADIUS_TO_EXTENDS); 146 | 147 | const clusters = featureList.length > 1 ? superC.getClusters(boundary, Math.round(zoom)) : featureList; 148 | 149 | return { 150 | superC, 151 | clusters 152 | }; 153 | }; 154 | 155 | /** 156 | * Find the list of point that have a similar location (lngLat) 157 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 158 | * @param {Coordinate} lngLat Required. The coordinate follow format [longitude, latitude] 159 | * @param {MapBox} mapBox Required. The mapbox instance 160 | * @param {number} radius The radius of the cluster 161 | * @param {number} zoom The zoom level, at which the points is clustered 162 | * @return {Array} The list of point at the same location. Null if cannot find the 163 | * similar points 164 | */ 165 | const findPointsWithSameLocation = (data, lngLat, mapBox, radius = 5, zoom) => { 166 | if (!data || !data.features || !_.isArray(data.features)) { 167 | throw new Error("Data cannot be empty"); 168 | } 169 | 170 | if (!lngLat || !_.isArray(lngLat)) { 171 | throw new Error("Specific location cannot be empty"); 172 | } 173 | 174 | if (!mapBox) { 175 | throw new Error("Mapbox instance must be provided"); 176 | } 177 | 178 | const { clusters, superC } = createClusters(data, mapBox, radius, zoom); 179 | const clusterAtLngLat = clusters.find(cluster => 180 | _.isEqual(roundCoords(cluster.geometry.coordinates), roundCoords(lngLat)) 181 | ); 182 | 183 | if (clusterAtLngLat) { 184 | const { cluster, cluster_id, point_count } = clusterAtLngLat.properties; 185 | if (cluster && point_count > 1) { 186 | try { 187 | return superC.getLeaves(cluster_id, Infinity); 188 | } catch (e) { 189 | return null; 190 | } 191 | } 192 | } 193 | 194 | return null; 195 | }; 196 | 197 | /** 198 | * Group the list of point that inside a specific radius 199 | * @param {FeatureCollection} data Required. A FeatureCollection of Point type 200 | * @param {MapBox} mapBox Required. The mapbox instance 201 | * @param {number} radius Optional. The radius of the cluster 202 | * @return {Array>} The list of grouped feature 203 | */ 204 | const groupNearestPointsByRadius = (data, mapBox, radius = 60) => { 205 | if (!data || !data.features || !_.isArray(data.features)) { 206 | throw new Error("Data cannot be empty"); 207 | } 208 | 209 | if (!mapBox) { 210 | throw new Error("Mapbox instance must be provided"); 211 | } 212 | 213 | const zoom = mapBox.getMaxZoom() - 2; 214 | let { clusters, superC } = createClusters(data, mapBox, radius, zoom); 215 | clusters = clusters.map(cluster => { 216 | const { cluster: isCluster, cluster_id } = cluster.properties; 217 | if (isCluster) { 218 | try { 219 | return superC.getLeaves(cluster_id, Infinity); 220 | } catch (e) { 221 | return null; 222 | } 223 | } 224 | 225 | return [cluster]; 226 | }); 227 | 228 | return _.filter(clusters); 229 | }; 230 | 231 | export { createClusters, findPointsWithSameLocation, groupNearestPointsByRadius }; 232 | -------------------------------------------------------------------------------- /src/lib/common/utils/event.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | const EVENT_PREFIX = /^on(.+)$/i; 4 | 5 | export const extractEventHandlers = (props, eventPrefix = EVENT_PREFIX) => { 6 | return _.reduce( 7 | Object.keys(props), 8 | (res, prop) => { 9 | const cb = props[prop]; 10 | if (eventPrefix.test(prop) && _.isFunction(cb)) { 11 | const key = prop.replace(eventPrefix, (match, p) => `on${p}`); 12 | res[key] = cb; 13 | } 14 | return res; 15 | }, 16 | {} 17 | ); 18 | }; 19 | 20 | export const getExactEventHandlerName = event => { 21 | if (!_.isString(event)) { 22 | return event; 23 | } 24 | 25 | return event.replace("on", "").toLowerCase(); 26 | }; 27 | -------------------------------------------------------------------------------- /src/lib/common/utils/index.js: -------------------------------------------------------------------------------- 1 | import { calculateNextZoomLevel } from "./calc"; 2 | import { createClusters, findPointsWithSameLocation, groupNearestPointsByRadius } from "./cluster"; 3 | import { extractEventHandlers, getExactEventHandlerName } from "./event"; 4 | import { checkPropsChange } from "./props"; 5 | import { isReactComponent } from "./react"; 6 | 7 | export { 8 | calculateNextZoomLevel, 9 | createClusters, 10 | checkPropsChange, 11 | extractEventHandlers, 12 | findPointsWithSameLocation, 13 | getExactEventHandlerName, 14 | groupNearestPointsByRadius, 15 | isReactComponent 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/common/utils/props.js: -------------------------------------------------------------------------------- 1 | import _ from "lodash"; 2 | 3 | export const checkPropsChange = (props, nextProps, keys, equalityChecker = _.isEqual) => { 4 | const propsToCheck = _.pick(props, keys); 5 | const nextPropsToCheck = _.pick(nextProps, keys); 6 | 7 | if (_.isFunction(equalityChecker)) { 8 | return equalityChecker(propsToCheck, nextPropsToCheck); 9 | } 10 | 11 | return propsToCheck === nextPropsToCheck; 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/common/utils/react.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import _ from "lodash"; 3 | 4 | const isFunctionComponent = component => 5 | component && _.isFunction(component.type) && String(component.type).includes("createElement"); 6 | 7 | /** 8 | * Check if a component is a custom class React component or native DOM elements (e.g. div, span) 9 | * @param {*} component 10 | * @return {bool} True if the input component is React component 11 | */ 12 | export const isReactComponent = component => { 13 | const isReactComponent = _.get(component, "type.prototype.isReactComponent"); 14 | const isPureReactComponent = _.get(component, "type.prototype.isPureReactComponent"); 15 | const isFunctionalComponent = isFunctionComponent(component); 16 | const isFragmentComponent = _.toString(_.get(component, "type")) === "Symbol(react.fragment)"; 17 | const isReactMemoComponent = _.toString(_.get(component, "$$typeof")) === "Symbol(react.memo)"; 18 | 19 | return ( 20 | isReactMemoComponent || 21 | (React.isValidElement(component) && 22 | (isReactComponent || isPureReactComponent || isFunctionalComponent || isFragmentComponent)) 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/lib/components/MappedComponent.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | import { MapContext } from "react-mapbox-gl"; 3 | 4 | class MappedComponent extends Component { 5 | static contextType = MapContext; 6 | 7 | getMapInstance() { 8 | return this.context; 9 | } 10 | } 11 | 12 | export default MappedComponent; 13 | -------------------------------------------------------------------------------- /src/lib/components/MarkerLayer/Component.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import PropTypes from "prop-types"; 4 | import _ from "lodash"; 5 | import MapboxGl from "mapbox-gl"; 6 | import { checkPropsChange, extractEventHandlers, getExactEventHandlerName } from "../../common/utils"; 7 | import MappedComponent from "../MappedComponent"; 8 | 9 | class MarkerLayer extends MappedComponent { 10 | componentDidMount() { 11 | const node = this.attachChildren(this.props); 12 | this.layer = new MapboxGl.Marker(node).setLngLat(this.props.coordinates).addTo(this.getMapInstance()); 13 | } 14 | 15 | componentDidUpdate(prevProps, prevState) { 16 | if (prevProps.coordinates !== this.props.coordinates) { 17 | this.layer.setLngLat(prevProps.coordinates); 18 | } 19 | if (prevProps.children !== this.props.children || checkPropsChange(this.props, prevProps, ["style", "className"])) { 20 | this.attachChildren(prevProps); 21 | } 22 | } 23 | 24 | componentWillUnmount() { 25 | if (!this.layer) { 26 | return; 27 | } 28 | this.layer.remove(); 29 | delete this.layer; 30 | } 31 | 32 | attachChildren(props = this.props) { 33 | const { children } = props; 34 | 35 | if (children) { 36 | if (!this.element) { 37 | this.element = document.createElement("div"); 38 | } else { 39 | this._unbindEvents(); 40 | } 41 | 42 | const style = this.getStyle(this.props); 43 | this.element.className = this.getContainerClassName(props); 44 | Object.keys(style).forEach(s => { 45 | this.element.style[s] = style[s]; 46 | }); 47 | this._bindEvents(); 48 | 49 | const content = this.getContent(props); 50 | ReactDOM.render(content, this.element); 51 | } 52 | 53 | return this.element; 54 | } 55 | 56 | getContainerClassName(props) { 57 | return `mapboxgl-marker ${props.className}`; 58 | } 59 | 60 | getContent(props) { 61 | const { children } = props; 62 | return
{children}
; 63 | } 64 | 65 | getProperties() { 66 | return this.props.properties; 67 | } 68 | 69 | getOffset() { 70 | return [0, 0]; 71 | } 72 | 73 | getStyle(props) { 74 | return _.clone(props.style) || {}; 75 | } 76 | 77 | _bindEvents() { 78 | const events = extractEventHandlers(this.props); 79 | this.realHandlers = {}; 80 | _.forEach(events, (handler, name) => { 81 | const realHandler = this._generateEventHander(name); 82 | this.element.addEventListener(getExactEventHandlerName(name), realHandler); 83 | this.realHandlers[name] = realHandler; 84 | }); 85 | this.element.addEventListener("mousedown", this._disableMapDragPan); 86 | this.element.addEventListener("mouseup", this._enableMapDragPan); 87 | } 88 | 89 | _disableMapDragPan = () => { 90 | const map = this.getMapInstance(); 91 | if (map) { 92 | map.dragPan.disable(); 93 | } 94 | }; 95 | 96 | _enableMapDragPan = () => { 97 | const map = this.getMapInstance(); 98 | if (map) { 99 | map.dragPan.enable(); 100 | } 101 | }; 102 | 103 | _generateEventHander = eventName => e => { 104 | const handler = this.props[eventName]; 105 | if (_.isFunction(handler)) { 106 | const { coordinates } = this.props; 107 | const properties = this.getProperties(); 108 | handler(properties, coordinates, this.getOffset(), e); 109 | } 110 | }; 111 | 112 | _unbindEvents() { 113 | const events = extractEventHandlers(this.props); 114 | this.element.removeEventListener("mousedown", this._disableMapDragPan); 115 | this.element.removeEventListener("mouseup", this._enableMapDragPan); 116 | _.forEach(events, (handler, name) => { 117 | const realHandler = this.realHandlers[name]; 118 | this.element.removeEventListener(getExactEventHandlerName(name), realHandler); 119 | }); 120 | 121 | delete this.realHandlers; 122 | } 123 | 124 | render() { 125 | return null; 126 | } 127 | } 128 | 129 | MarkerLayer.displayName = "MarkerLayer"; 130 | 131 | MarkerLayer.propTypes = { 132 | /** 133 | * (required): [number, number] Display the Marker at the given position 134 | */ 135 | coordinates: PropTypes.array.isRequired, 136 | 137 | /** 138 | * Properties of each Marker, will be passed back when events trigged 139 | */ 140 | properties: PropTypes.oneOfType([ 141 | PropTypes.array.isRequired, 142 | PropTypes.object.isRequired, 143 | PropTypes.string.isRequired 144 | ]), 145 | 146 | /** 147 | * Apply the className to the container of the Marker 148 | */ 149 | className: PropTypes.string, 150 | 151 | /** 152 | * Apply style to the Marker container 153 | */ 154 | style: PropTypes.object, 155 | 156 | /** 157 | * Child node(s) of the component, to be rendered as custom Marker 158 | */ 159 | children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), 160 | 161 | /** 162 | * [Optional] The click event handler 163 | */ 164 | onClick: PropTypes.func, 165 | 166 | /** 167 | * [Optional] The mouse down event handler 168 | */ 169 | onMouseDown: PropTypes.func, 170 | 171 | /** 172 | * [Optional] The mouse enter event handler 173 | */ 174 | onMouseEnter: PropTypes.func, 175 | 176 | /** 177 | * [Optional] The mouse leave event handler 178 | */ 179 | onMouseLeave: PropTypes.func, 180 | 181 | /** 182 | * [Optional] The mouse move event handler 183 | */ 184 | onMouseMove: PropTypes.func, 185 | 186 | /** 187 | * [Optional] The mouse out event handler 188 | */ 189 | onMouseOut: PropTypes.func, 190 | 191 | /** 192 | * [Optional] The mouse over event handler 193 | */ 194 | onMouseOver: PropTypes.func, 195 | 196 | /** 197 | * [Optional] The mouse up event handler 198 | */ 199 | onMouseUp: PropTypes.func 200 | }; 201 | 202 | export default MarkerLayer; 203 | -------------------------------------------------------------------------------- /src/lib/components/MarkerLayer/index.jsx: -------------------------------------------------------------------------------- 1 | import MarkerLayer from "./Component"; 2 | 3 | export { MarkerLayer }; 4 | -------------------------------------------------------------------------------- /src/lib/components/ReactMapboxGlCluster/ClusterLayer.css: -------------------------------------------------------------------------------- 1 | .cluster-layer-container { 2 | width: 30px; 3 | height: 30px; 4 | } 5 | 6 | .cluster-layer--cluster { 7 | width: 30px; 8 | height: 30px; 9 | border-radius: 50%; 10 | background-color: rgba(33, 150, 243, 0.8); 11 | display: flex; 12 | justify-content: center; 13 | align-items: center; 14 | color: white; 15 | cursor: pointer; 16 | } 17 | 18 | .cluster-layer--cluster:before { 19 | content: " "; 20 | position: absolute; 21 | border-radius: 50%; 22 | width: 40px; 23 | height: 40px; 24 | background-color: rgba(33, 150, 243, 0.6); 25 | z-index: -1; 26 | } 27 | 28 | .cluster-layer--point { 29 | width: 20px; 30 | height: 20px; 31 | border-radius: 50%; 32 | background-color: rgba(195, 38, 94, 0.8); 33 | border: 1px solid #ffffff; 34 | } 35 | 36 | .cluster-layer--point:hover { 37 | width: 25px; 38 | height: 25px; 39 | border-width: 2px !important; 40 | } 41 | 42 | .marker-content.hovered .cluster-layer--cluster { 43 | width: 40px; 44 | height: 40px; 45 | } 46 | 47 | .marker-content.hovered .cluster-layer--cluster:before { 48 | width: 50px; 49 | height: 50px; 50 | } 51 | 52 | .marker-content.hovered .cluster-layer--point { 53 | width: 25px; 54 | height: 25px; 55 | border-color: #51dbd3 !important; 56 | border-width: 2px !important; 57 | } 58 | -------------------------------------------------------------------------------- /src/lib/components/ReactMapboxGlCluster/ClusterLayer.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import classnames from "classnames"; 4 | import { getCoord } from "@turf/invariant"; 5 | import { Cluster } from "react-mapbox-gl"; 6 | import { extractEventHandlers } from "../../common/utils"; 7 | import { MarkerLayer } from "../MarkerLayer"; 8 | import "./ClusterLayer.css"; 9 | 10 | class ClusterLayer extends PureComponent { 11 | _clusterMarkerFactory = (coordinates, pointCount, getLeaves) => { 12 | const { clusterClassName } = this.props; 13 | const className = classnames("cluster-layer--cluster", clusterClassName); 14 | const points = getLeaves(); 15 | const pointsProps = this._getPointsProps(points); 16 | const clusterEventHandlers = extractEventHandlers(this.props, /^onCluster(.+)$/i); 17 | 18 | return ( 19 | 26 |
27 |
{pointCount}
28 |
29 |
30 | ); 31 | }; 32 | 33 | _getClusterProps() { 34 | const { radius, minZoom, maxZoom, extent, nodeSize } = this.props; 35 | 36 | return { 37 | radius, 38 | minZoom, 39 | maxZoom, 40 | extent, 41 | nodeSize 42 | }; 43 | } 44 | 45 | _getPointsProps(points) { 46 | return points.map(point => { 47 | const feature = point.props["data-feature"]; 48 | const { properties } = feature; 49 | return { 50 | ...properties, 51 | coordinates: getCoord(feature) 52 | }; 53 | }); 54 | } 55 | 56 | _renderMarkers() { 57 | const { data, pointClassName, pointStyles = {}, markerComponent: MarkerComponent } = this.props; 58 | const markerClassName = classnames("cluster-layer--point", pointClassName); 59 | 60 | return data.features.map((feature, key) => { 61 | const { 62 | geometry: { coordinates }, 63 | properties 64 | } = feature; 65 | const { style } = properties; 66 | const eventHandlers = extractEventHandlers(this.props); 67 | const cssObject = { 68 | ...pointStyles, 69 | ...style 70 | }; 71 | 72 | return ( 73 | 80 | {MarkerComponent ? ( 81 | 82 | ) : ( 83 |
84 | )} 85 | 86 | ); 87 | }); 88 | } 89 | 90 | render() { 91 | const clusterProps = this._getClusterProps(); 92 | 93 | return ( 94 | 95 | {this._renderMarkers()} 96 | 97 | ); 98 | } 99 | } 100 | 101 | ClusterLayer.displayName = "ClusterLayer"; 102 | 103 | ClusterLayer.propTypes = { 104 | /** 105 | * Data source for layer. It must to follow FeatureCollection geojson format 106 | */ 107 | data: PropTypes.shape({ 108 | type: PropTypes.oneOf(["FeatureCollection"]).isRequired, 109 | features: PropTypes.arrayOf( 110 | PropTypes.shape({ 111 | type: PropTypes.oneOf(["Feature"]).isRequired, 112 | geometry: PropTypes.shape({ 113 | type: PropTypes.string.isRequired, 114 | coordinates: PropTypes.array.isRequired 115 | }).isRequired, 116 | properties: PropTypes.object.isRequired 117 | }) 118 | ).isRequired 119 | }), 120 | 121 | /** 122 | * [Optional] Cluster radius, in pixels. 123 | */ 124 | radius: PropTypes.number, 125 | 126 | /** 127 | * [Optional] Minimum zoom level at which clusters are generated. 128 | */ 129 | minZoom: PropTypes.number, 130 | 131 | /** 132 | * [Optional] Maximum zoom level at which clusters are generated. 133 | */ 134 | maxZoom: PropTypes.number, 135 | 136 | /** 137 | * [Optional] (Tiles) Tile extent. Radius is calculated relative to this value. 138 | */ 139 | extent: PropTypes.number, 140 | 141 | /** 142 | * [Optional] Size of the KD-tree leaf node. Affects performance. 143 | */ 144 | nodeSize: PropTypes.number, 145 | 146 | /** 147 | * [Optional] The class name of each point. 148 | */ 149 | pointClassName: PropTypes.string, 150 | 151 | /** 152 | * [Optional] The styles name of each point. 153 | */ 154 | pointStyles: PropTypes.object, 155 | 156 | /** 157 | * [Optional] The class name of each cluster. 158 | */ 159 | clusterClassName: PropTypes.string, 160 | 161 | /** 162 | * [Optional] Customize the marker 163 | */ 164 | markerComponent: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), 165 | 166 | /** 167 | * [Optional] Handle when user move the mouse leave a point 168 | */ 169 | onMouseLeave: PropTypes.func, 170 | 171 | /** 172 | * [Optional] Handler for when user on marker 173 | **/ 174 | onClick: PropTypes.func, 175 | 176 | /** 177 | * [Optional] Handle when user click on cluster 178 | */ 179 | onClusterClick: PropTypes.func, 180 | 181 | /** 182 | * [Optional] Handle when user move the mouse enter a cluster 183 | */ 184 | onClusterMouseEnter: PropTypes.func, 185 | 186 | /** 187 | * [Optional] Handle when user move the mouse leave a cluster 188 | */ 189 | onClusterMouseLeave: PropTypes.func 190 | }; 191 | 192 | ClusterLayer.defaultProps = { 193 | radius: 60, 194 | minZoom: 0, 195 | maxZoom: 20, 196 | extent: 512, 197 | nodeSize: 64 198 | }; 199 | 200 | export default ClusterLayer; 201 | -------------------------------------------------------------------------------- /src/lib/components/ReactMapboxGlCluster/MapboxGlCluster.jsx: -------------------------------------------------------------------------------- 1 | import { connectWithSpiderifierPoint, detectLocationHasOverlappedPoints, doZoomingOnClick } from "../../common/hoc"; 2 | import ClusterLayer from "./ClusterLayer"; 3 | 4 | const ClusterLayerWithOverlappedPoints = detectLocationHasOverlappedPoints(ClusterLayer); 5 | 6 | const ZoomableClusterLayer = doZoomingOnClick(ClusterLayerWithOverlappedPoints); 7 | 8 | const MapboxGlCluster = connectWithSpiderifierPoint(ZoomableClusterLayer); 9 | 10 | export default MapboxGlCluster; 11 | -------------------------------------------------------------------------------- /src/lib/components/ReactMapboxGlCluster/index.js: -------------------------------------------------------------------------------- 1 | import MapboxGlCluster from "./MapboxGlCluster"; 2 | 3 | export default MapboxGlCluster; 4 | -------------------------------------------------------------------------------- /src/lib/components/ReactMapboxGlCluster/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "cluster-layer", 4 | "main": "./index.js" 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/index.js: -------------------------------------------------------------------------------- 1 | import ReactMapboxGlCluster from "./components/ReactMapboxGlCluster"; 2 | 3 | export { ReactMapboxGlCluster }; 4 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | export function register(config) { 22 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener("load", () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Let's check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl, config); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | "This web app is being served cache-first by a service " + 44 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 45 | ); 46 | }); 47 | } else { 48 | // Is not localhost. Just register service worker 49 | registerValidSW(swUrl, config); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl, config) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | if (installingWorker == null) { 62 | return; 63 | } 64 | installingWorker.onstatechange = () => { 65 | if (installingWorker.state === "installed") { 66 | if (navigator.serviceWorker.controller) { 67 | // At this point, the updated precached content has been fetched, 68 | // but the previous service worker will still serve the older 69 | // content until all client tabs are closed. 70 | console.log( 71 | "New content is available and will be used when all " + 72 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 73 | ); 74 | 75 | // Execute callback 76 | if (config && config.onUpdate) { 77 | config.onUpdate(registration); 78 | } 79 | } else { 80 | // At this point, everything has been precached. 81 | // It's the perfect time to display a 82 | // "Content is cached for offline use." message. 83 | console.log("Content is cached for offline use."); 84 | 85 | // Execute callback 86 | if (config && config.onSuccess) { 87 | config.onSuccess(registration); 88 | } 89 | } 90 | } 91 | }; 92 | }; 93 | }) 94 | .catch(error => { 95 | console.error("Error during service worker registration:", error); 96 | }); 97 | } 98 | 99 | function checkValidServiceWorker(swUrl, config) { 100 | // Check if the service worker can be found. If it can't reload the page. 101 | fetch(swUrl) 102 | .then(response => { 103 | // Ensure service worker exists, and that we really are getting a JS file. 104 | const contentType = response.headers.get("content-type"); 105 | if (response.status === 404 || (contentType != null && contentType.indexOf("javascript") === -1)) { 106 | // No service worker found. Probably a different app. Reload the page. 107 | navigator.serviceWorker.ready.then(registration => { 108 | registration.unregister().then(() => { 109 | window.location.reload(); 110 | }); 111 | }); 112 | } else { 113 | // Service worker found. Proceed as normal. 114 | registerValidSW(swUrl, config); 115 | } 116 | }) 117 | .catch(() => { 118 | console.log("No internet connection found. App is running in offline mode."); 119 | }); 120 | } 121 | 122 | export function unregister() { 123 | if ("serviceWorker" in navigator) { 124 | navigator.serviceWorker.ready.then(registration => { 125 | registration.unregister(); 126 | }); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/create-react-app/tsconfig.json", 3 | "include": [ 4 | "src/lib/**/*.js" 5 | ], 6 | "compilerOptions": { 7 | "emitDeclarationOnly": true, 8 | "noEmit": false, 9 | "declaration": true, 10 | "outDir": "dist/types" 11 | } 12 | } 13 | --------------------------------------------------------------------------------