Merge pull request #888 from sharetribe/init-mapbox

Init mapbox with the access token in env
This commit is contained in:
Vesa Luusua 2018-09-19 17:51:19 +03:00 committed by GitHub
commit 5bd4873f25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 5539 additions and 522 deletions

View file

@ -2,8 +2,11 @@
# Mandatory configuration
#
REACT_APP_SHARETRIBE_SDK_CLIENT_ID=
REACT_APP_GOOGLE_MAPS_API_KEY=
REACT_APP_STRIPE_PUBLISHABLE_KEY=
REACT_APP_MAPBOX_ACCESS_TOKEN=
# Or set up an alternative map provider (Google Maps). Check documentation.
# REACT_APP_GOOGLE_MAPS_API_KEY=
# Defaults
#

View file

@ -11,20 +11,40 @@ way to update this template, but currently, we follow a pattern:
* Patch (v0.0.**X**): Bug fixes and small changes to components.
---
## v2.0.0 2018-09-19
* [add] New default map provider (Mapbox) and complete refactoring to all map and geocoding
components. [#888](https://github.com/sharetribe/flex-template-web/pull/888)
**Note:** Before updating to version 2.0.0, you should very carefully track customizations that
you have made to following components:
* **LocationAutocompleteInput**
* **Map**
* **SearchPage** (especially previous `onIdle` function)
* **SearchMap**
* **SearchMapPriceLabel**
* **SearchMapGroupLabel**
* **SearchMapInfoCard**
To get a better understanding of what has changed, you should read documents about how to
[integrate to map providers](./docs/map-providers.md) and especially
[changing map provider to Google Maps](./docs/google-maps.md)
## v1.4.3 2018-09-15
* [fix] fuzzy location didn't change when listing location changed.
[#931](https://github.com/sharetribe/flex-template-web/pull/931)
* [fix] obfuscatedCoordinatesImpl didn't always return coordinates within given
offset radius.
* [fix] obfuscatedCoordinatesImpl didn't always return coordinates within given offset radius.
[#930](https://github.com/sharetribe/flex-template-web/pull/930)
* [fix] LocationAutocompleteInput: blur input when selecting by enter
to prevent flash of default predictions.
[#928](https://github.com/sharetribe/flex-template-web/pull/928)
* [fix] LocationAutocompleteInput: selecting with enter key prevented
while fetching predictions.
* [fix] LocationAutocompleteInput: blur input when selecting by enter to prevent flash of default
predictions. [#928](https://github.com/sharetribe/flex-template-web/pull/928)
* [fix] LocationAutocompleteInput: selecting with enter key prevented while fetching predictions.
[#923](https://github.com/sharetribe/flex-template-web/pull/923)
## v1.4.2 2018-09-06
* [add] Reduce character queries on LocationAutocompleteInput to reduce geocoding costs.
[#883](https://github.com/sharetribe/flex-template-web/pull/883)
* [change] Update git links and improve documentation
@ -37,26 +57,24 @@ way to update this template, but currently, we follow a pattern:
[#906](https://github.com/sharetribe/flex-template-web/pull/906)
## v1.4.1 2018-08-21
* [fix] Fix window resize redirecting to search page with reusable map component
[#905](https://github.com/sharetribe/flex-template-web/pull/905)
* [change] Maps configuration has been restructured. The new
configuration is agnostic of the maps provider in use and works with
both Google Maps as well as Mapbox.
* [change] Maps configuration has been restructured. The new configuration is agnostic of the maps
provider in use and works with both Google Maps as well as Mapbox.
The fuzzy location circle has less configuration, but otherwise all
the previous settings can be set also in the new configuration. See
`config.js` for details.
The fuzzy location circle has less configuration, but otherwise all the previous settings can be
set also in the new configuration. See `config.js` for details.
The default location searches are now enabled in the
`.env-template`. For old installations, the
`REACT_APP_DEFAULT_SEARCHES_ENABLED` env var should be set to
`true`. The default searches can then be configured in
`src/default-location-searches.js`.
The default location searches are now enabled in the `.env-template`. For old installations, the
`REACT_APP_DEFAULT_SEARCHES_ENABLED` env var should be set to `true`. The default searches can
then be configured in `src/default-location-searches.js`.
[#900](https://github.com/sharetribe/flex-template-web/pull/900)
## v1.4.0 2018-08-17
* [change] Put availability calendar behind a feature flag
[#902](https://github.com/sharetribe/flex-template-web/pull/902)
* [fix] Drop date time from time slots request query params
@ -65,37 +83,36 @@ way to update this template, but currently, we follow a pattern:
[#901](https://github.com/sharetribe/flex-template-web/pull/901)
* [add] Map component (used in ListingPage) using Mapbox instead of Google Maps
[#896](https://github.com/sharetribe/flex-template-web/pull/896)
* [add] Listing availability
[#868](https://github.com/sharetribe/flex-template-web/pull/868), [#873](https://github.com/sharetribe/flex-template-web/pull/873), [#891](https://github.com/sharetribe/flex-template-web/pull/891) & [#892](https://github.com/sharetribe/flex-template-web/pull/892)
* [add] Add support for user's current location as a default
suggestion in the location autocomplete search.
[#895](https://github.com/sharetribe/flex-template-web/pull/895)
* [add] Add support for default locations in the
LocationAutocompleteInput component. Common searches can be
configured to show when the input has focus. This reduces typing and
Google Places geolocation API usage. The defaults can be configured
in `src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js`.
* [add] Listing availability [#868](https://github.com/sharetribe/flex-template-web/pull/868),
[#873](https://github.com/sharetribe/flex-template-web/pull/873),
[#891](https://github.com/sharetribe/flex-template-web/pull/891) &
[#892](https://github.com/sharetribe/flex-template-web/pull/892)
* [add] Add support for user's current location as a default suggestion in the location autocomplete
search. [#895](https://github.com/sharetribe/flex-template-web/pull/895)
* [add] Add support for default locations in the LocationAutocompleteInput component. Common
searches can be configured to show when the input has focus. This reduces typing and Google Places
geolocation API usage. The defaults can be configured in
`src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js`.
[#894](https://github.com/sharetribe/flex-template-web/pull/894)
* [change] Removed the `country` parameter from the search page as it
was not used anywhere.
* [change] Removed the `country` parameter from the search page as it was not used anywhere.
[#893](https://github.com/sharetribe/flex-template-web/pull/893)
## v1.3.2 2018-08-07
* [change] Update the Sharetribe Flex SDK to the 1.0.0 version in NPM.
All the `sharetribe-sdk` imports are now using the new package name
`sharetribe-flex-sdk`.
* [change] Update the Sharetribe Flex SDK to the 1.0.0 version in NPM. All the `sharetribe-sdk`
imports are now using the new package name `sharetribe-flex-sdk`.
[#884](https://github.com/sharetribe/flex-template-web/pull/884)
* [change] Reusable SearchMap. Fixed the original reverted version. (Includes audit exception 678)
[#882](https://github.com/sharetribe/flex-template-web/pull/882)
## v1.3.1
* [fix] Hotfix: reverting the usage of ReusableMapContainer due to
production build error.
* [fix] Hotfix: reverting the usage of ReusableMapContainer due to production build error.
[#881](https://github.com/sharetribe/flex-template-web/pull/881)
## v1.3.0
* [change] Reusable SearchMap.
[#877](https://github.com/sharetribe/flex-template-web/pull/877)
* [change] Reusable SearchMap. [#877](https://github.com/sharetribe/flex-template-web/pull/877)
* [fix] Fix a search filters panel bug where selecting an option in a multi select filter ends up
invoking a mobile filter callback function.
[#876](https://github.com/sharetribe/flex-template-web/pull/876)
@ -103,22 +120,26 @@ way to update this template, but currently, we follow a pattern:
[#874](https://github.com/sharetribe/flex-template-web/pull/874)
## v1.2.2
* [change] Change static map to dynamic map when clicked.
[#871](https://github.com/sharetribe/flex-template-web/pull/871)
## v1.2.1
* [fix] Lazy load map only if the map is near current viewport.
[#871](https://github.com/sharetribe/flex-template-web/pull/871)
## v1.2.0
* [change] Use Google's static map on ListingPage.
This is a reaction to pricing change of Google Maps APIs.
[#869](https://github.com/sharetribe/flex-template-web/pull/869)
* [change] Use sessionTokens and fields for Autocomplete calls to Google Maps.
This is a reaction to pricing change of Google Maps APIs.
* [change] Use Google's static map on ListingPage. This is a reaction to pricing change of Google
Maps APIs. [#869](https://github.com/sharetribe/flex-template-web/pull/869)
* [change] Use sessionTokens and fields for Autocomplete calls to Google Maps. This is a reaction to
pricing change of Google Maps APIs.
[#867](https://github.com/sharetribe/flex-template-web/pull/867)
* [change] Change TransactionPage state management in loadData.
[#863](https://github.com/sharetribe/flex-template-web/pull/863), [#865](https://github.com/sharetribe/flex-template-web/pull/865) & [#866](https://github.com/sharetribe/flex-template-web/pull/866)
[#863](https://github.com/sharetribe/flex-template-web/pull/863),
[#865](https://github.com/sharetribe/flex-template-web/pull/865) &
[#866](https://github.com/sharetribe/flex-template-web/pull/866)
* [fix] Fix submit button state on contact details page.
[#864](https://github.com/sharetribe/flex-template-web/pull/864)
* [fix] Fix listing page host section layout bug.
@ -126,24 +147,23 @@ way to update this template, but currently, we follow a pattern:
* [fix] Fix initial message input clearing too early in checkout page.
[#861](https://github.com/sharetribe/flex-template-web/pull/861)
* [fix] Fix setting Topbar search input initial value.
* [change] Update Redux to v4
[#859](https://github.com/sharetribe/flex-template-web/pull/859)
* [change] Update Redux to v4 [#859](https://github.com/sharetribe/flex-template-web/pull/859)
* [fix] Fix setting Topbar search input initial value
[#857](https://github.com/sharetribe/flex-template-web/pull/857)
## v1.1.0
* [fix] Improve slug creation (slashes were breaking rendering in some environments)
[#850](https://github.com/sharetribe/flex-template-web/pull/850)
* [fix] Anonymous user should see contact link on UserCard
[#851](https://github.com/sharetribe/flex-template-web/pull/851)
* [fix] Persisting booking request details across authentication
[#852](https://github.com/sharetribe/flex-template-web/pull/852)
* [change] Footer styles changed to more generic (no disappearing columns etc.)
If you have made changes to Footer, consider extracting it to different component before update.
* [change] Footer styles changed to more generic (no disappearing columns etc.) If you have made
changes to Footer, consider extracting it to different component before update.
[#853](https://github.com/sharetribe/flex-template-web/pull/853)
* [change] Logo customization refactored to be easier. Check CheckoutPage, TopbarDesktop and Footer
after update.
[#854](https://github.com/sharetribe/flex-template-web/pull/854)
after update. [#854](https://github.com/sharetribe/flex-template-web/pull/854)
* [fix] Fix showing reviews from banned users.
[#855](https://github.com/sharetribe/flex-template-web/pull/855)
@ -156,9 +176,8 @@ way to update this template, but currently, we follow a pattern:
[#846](https://github.com/sharetribe/flex-template-web/pull/846)
* [fix] Add missing styles for ModalMissingInformation from Topbar
[#847](https://github.com/sharetribe/flex-template-web/pull/847)
* [fix] API does not return all image variants anymore, this adds correct variants to update
contact details call.
[#848](https://github.com/sharetribe/flex-template-web/pull/848)
* [fix] API does not return all image variants anymore, this adds correct variants to update contact
details call. [#848](https://github.com/sharetribe/flex-template-web/pull/848)
## v0.3.1

View file

@ -35,6 +35,7 @@ Documentation for specific topics can be found in the following files:
* [Customization guide](customization-guide.md)
* [Folder structure](folder-structure.md)
* [Integration to map providers](map-providers.md)
* [Translations](translations.md)
* [Styling a marketplace](styling.md)
* [Static pages](static-pages.md)
@ -49,7 +50,6 @@ Documentation for specific topics can be found in the following files:
* [Content Security Policy (CSP)](content-security-policy.md)
* [Original create-react-app documentation](https://github.com/sharetribe/create-react-app/blob/master/packages/react-scripts/template/README.md)
* [Customization checklist](customization-checklist.md)
* [Google Maps](google-maps.md)
* [Icons](icons.md)
The application was bootstrapped with a forked version of

View file

@ -11,7 +11,8 @@ Some generic things to update and check when starting to customize the template.
* [Terms of Service](terms-of-service-and-privacy-policy.md#terms-of-service)
* [Privacy Policy](terms-of-service-and-privacy-policy.md#privacy-policy)
* [Social media sharing graphics](../src/components/Page/Page.js);
* [Logo](../src/components/Logo/Logo.js) Change and check that it works on Topbar, Footer, and CheckoutPage
* [Logo](../src/components/Logo/Logo.js) Change and check that it works on Topbar, Footer, and
CheckoutPage
* [Default background image](../src/assets/background-1440.jpg)
* [Maps Marker icon](../src/components/Map/images/marker-32x32.png)
* [Config: update environment variables](../src/config.js)

View file

@ -5,7 +5,8 @@ them have defaults that work for development environment. For production deploys
| Variable | Description |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| REACT_APP_GOOGLE_MAPS_API_KEY | See: [Google Maps API key](./google-maps.md) |
| REACT_APP_MAPBOX_ACCESS_TOKEN | See: [Integrating to map providers](./map-providers.md) |
| REACT_APP_GOOGLE_MAPS_API_KEY | See: [Google Maps API key](./google-maps.md) (Alternative map provider) |
| REACT_APP_SHARETRIBE_SDK_CLIENT_ID | Client ID (API key). You will get this from the Sharetribe team. |
| REACT_APP_STRIPE_PUBLISHABLE_KEY | Stripe publishable API key for generating tokens with Stripe API. Use test key (prefix pk*test*) for development. |
| REACT_APP_SHARETRIBE_SDK_BASE_URL | The base url to access the Sharetribe Flex Marketplace API. |

View file

@ -42,8 +42,8 @@ After cloning the repo, your project should look like this:
## public/index.html
This is the page template. It includes fonts, Stripe SDK, Google Maps JavaScript API, and app icons.
It also specifies placeholders for tags generated by
This is the page template. It includes fonts, Stripe SDK, Mapbox API (and Google Maps JavaScript
API), and app icons. It also specifies placeholders for tags generated by
[React Helmet](https://github.com/nfl/react-helmet)
## src/

View file

@ -1,8 +1,14 @@
# Google Maps
The Flex template for web uses the Google Maps API for showing a map and in searching locations in
the search autocompletion. This document describes how to setup the API key for the API requests to
work properly.
Flex Template for Web (FTW) offers out of the box support for Google Maps API for showing a map and
searching locations with search autocompletion. This document describes how to set up the API key
for the API requests to work properly.
> Note: before making the change to Google Maps, you should consider if you are OK with their
> current pricing. There's a pricing calculator available in their
> [pricing page](https://cloud.google.com/maps-platform/pricing/). FTW's default map provider is
> Mapbox, which is often cheaper.
> [Learn more about how to use Mapbox or some other map provider instead of Google Maps](https://github.com/sharetribe/flex-template-web/blob/e6034c7690c095553f44092b032689cd6b6f7546/docs/map-providers.md).
## Generate a Google Maps API key
@ -28,15 +34,147 @@ REACT_APP_GOOGLE_MAPS_API_KEY=my-key-here
## Setup common locations to reduce typing
The location autocomplete input in the landing page and the topbar can
be configured to have specific locations shown by default when the
user focuses on the input and hasn't yet typed in any searches. This
reduces the typing required for common searches, and also reduces the
need to use the Google Places geolocation API that much.
The location autocomplete-input in the landing page and the topbar can be configured to have
specific locations shown by default when the user focuses on the input and hasn't yet typed in any
searches. This reduces the typing required for common searches and also reduces the need to use
Google Map Places API that much.
The default locations can be configured in
[src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js](../src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js).
To use default searches, another environment variable needs to be set:
You can also setup a default location that asks the user's current
location. This will enable e.g. searching listings near the user. The
current location is configured in the same file.
```
REACT_APP_DEFAULT_SEARCHES_ENABLED=true
```
The default locations have been described in file:
[src/default-location-searches.js](../src/default-location-searches.js).
The same environment variable also shows "current location" suggestion, which will make the browser
to ask user's current location. This is a fast way to search listings nearby. You can specify
whether to use the current location from [config.js](../src/config.js). Search for variables:
`suggestCurrentLocation` and `currentLocationBoundsDistance`.
## Change components: use Google Map versions instead of Mapbox
If you wish to use Google Maps instead of Mapbox, you need to make some changes to FTW default
setup.
### 1. Include Google Map script instead of Mapbox scripts
Mapbox related scripts can be removed from index.html and instead use Google Map script described in
comments.
_public/index.html:_
```html
<script src="%PUBLIC_URL%/static/scripts/mapbox/mapbox-sdk.min.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.js"></script>
<script>
window.mapboxgl.accessToken = '%REACT_APP_MAPBOX_ACCESS_TOKEN%';
</script>
<!--
If Google Maps is used instead of Mapbox, you need to include Google's script instead:
<script src="https://maps.googleapis.com/maps/api/js?key=%REACT_APP_GOOGLE_MAPS_API_KEY%&libraries=places"></script>
-->
```
### 2. Searching with Google's geocoding API
Location search aka LocationAutocompleteInput should use Google Map specific geocoder. The correct
import is written to the comments of LocationAutocompleteInput component.
_src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js:_
```js
import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderMapbox';
// import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderGoogleMaps';
```
Furthermore, Google Map states in their terms of service that Google logo needs to be visible when
using their geocoding service. It is available as a background image below the autocomplete
predictions. However, there needs to be enough padding for that logo. You can change the padding
through `marketplace.css`.
_src/marketplace.css:_
```js
/* Google Maps needs 72px bottom padding to accommodate logo, Mapbox doesn't have one */
--locationAutocompleteBottomPadding: 8px;
```
### 3. Show correct map on ListingPage (Map component)
Google Map version (containing both static and dynamic maps) can be taken into use by importing
correct map subcomponent.
_src/components/Map/Map.js:_
```js
import { StaticMap, DynamicMap, isMapsLibLoaded } from './MapboxMap';
// import { StaticMap, DynamicMap, isMapsLibLoaded } from './GoogleMap';
```
### 4. SearchMap.js
The most complex change is happening in SearchPage. First, you need to import `SearchMapWithMapbox`
instead of `SearchMapWithGoogleMap`.
_src/components/SearchMap/SearchMap.js:_
Remove this:
```js
import SearchMapWithMapbox, {
LABEL_HANDLE,
INFO_CARD_HANDLE,
getMapBounds,
getMapCenter,
fitMapToBounds,
isMapsLibLoaded,
} from './SearchMapWithMapbox';
```
And add this instead:
```js
import SearchMapWithGoogleMap, {
LABEL_HANDLE,
INFO_CARD_HANDLE,
getMapBounds,
getMapCenter,
fitMapToBounds,
isMapsLibLoaded,
} from './SearchMapWithGoogleMap';
```
Then, in `render` method, you need to put `SearchMapWithGoogleMap` component into use by replacing
`SearchMapWithMapbox` which is defined inside `ReusableMapContainer`. The component with correct
props is already there in the comments:
```js
// When changing from default map provider to Google Maps, you should use the following
// component instead of SearchMapWithMapbox:
//
// <SearchMapWithGoogleMap
// containerElement={
// <div id="search-map-container" className={classes} onClick={this.onMapClicked} />
// }
// mapElement={<div className={mapRootClassName || css.mapRoot} />}
// bounds={bounds}
// center={center}
// location={location}
// infoCardOpen={infoCardOpen}
// listings={listings}
// activeListingId={activeListingId}
// mapComponentRefreshToken={this.state.mapReattachmentCount}
// createURLToListing={this.createURLToListing}
// onListingClicked={this.onListingClicked}
// onListingInfoCardClicked={this.onListingInfoCardClicked}
// onMapLoad={this.onMapLoadHandler}
// onMapMoveEnd={onMapMoveEnd}
// zoom={zoom}
// />
```
The only extra step is to make `mapRootClassName` property available from `this.props` at the
beginning of the `render` method.

70
docs/map-providers.md Normal file
View file

@ -0,0 +1,70 @@
# Integrating to map providers
Choice of map provider can significantly impact your costs. Flex Template for Web (FTW) originally
supported only [Google Maps](./google-maps.md) out of the box, but after Google increased the
pricing of its APIs a lot, the default provider was changed to Mapbox. The template now supports
both Mapbox and Google Maps, and the map library used by Mapbox is used also by several other map
providers, so integrating new providers that support this is rather easy (see instructions at the
end of this doc).
## Setting up the Mapbox integration (the default map provider)
### 1. Generate a Mapbox access key
Sign up for a Mapbox and go to [account page](https://www.mapbox.com/account/). Then click
`Create access token`.
> Read more about
> [access tokens and consider rotating them](https://www.mapbox.com/help/how-access-tokens-work/).
### 2. Setup the application to use the access key
The application uses the `REACT_APP_MAPBOX_ACCESS_TOKEN` environment variable for the key value. For
local development, you can add the variable in the Gitignored `.env` file in the project root:
```
REACT_APP_MAPBOX_ACCESS_TOKEN=my-access-token-here
```
### 3. Setup common locations to reduce typing
The location autocomplete input in the landing page and the topbar can be configured to have
specific locations shown by default when the user focuses on the input and hasn't yet typed in any
searches. This reduces the typing required for common searches and also reduces the need to use
Mapbox geolocation API that much.
To use default searches, another environment variable needs to be set:
```
REACT_APP_DEFAULT_SEARCHES_ENABLED=true
```
The default locations are described in
[src/default-location-searches.js](../src/default-location-searches.js).
The same environment variable also shows "current location" suggestion, which will make the browser
to ask user's current location. This is a fast way to search listings nearby. You can specify
whether to use the current location from [config.js](../src/config.js). Search for variables:
`suggestCurrentLocation` and `currentLocationBoundsDistance`.
### 4. Check rare default configurations
Mapbox geocoding API doesn't always return bounding boxes for locations. Without bounding box
SearchMap component can't adjust zoom level right for that particular place. Therefore there are
default bounding boxes defined to different place types in
[Mapbox specific geocoder](../src/components/LocationAutocompleteInput/GeocoderMapbox.js).
## Changing the map providers
### How to change from Mapbox to Google Maps
It is possible to use Google Map instead of the default map provider. Read more from
[Google Map setup guide](./google-maps.md)
### How to use other map providers
The default map setup of FTW uses library called `mapbox-gl-js`. It is supported by quite many other
map providers too. Thus, if you wish to use a map provider other than Google Maps or Mapbox, first
check if the map provider you are considering is supporting this library. If they are, the change
might be quite easy. Note: if you change the map tile provider you should also change geocoding API
too (i.e. the API endpoint for `LocationAutocompleteInput` component).

View file

@ -1,6 +1,6 @@
{
"name": "app",
"version": "1.4.3",
"version": "2.0.0",
"private": true,
"license": "Apache-2.0",
"dependencies": {
@ -24,6 +24,7 @@
"final-form-arrays": "^1.0.4",
"helmet": "^3.12.0",
"lodash": "^4.17.5",
"mapbox-gl-multitouch": "^1.0.3",
"moment": "^2.20.1",
"object.entries": "^1.0.4",
"object.values": "^1.0.4",

View file

@ -143,7 +143,22 @@
})(document);
</script>
<script src="https://maps.googleapis.com/maps/api/js?key=%REACT_APP_GOOGLE_MAPS_API_KEY%&libraries=places"></script>
<!--
NOTE: remember to update mapbox-sdk.min.js to a new version regularly.
mapbox-sdk.min.js is included from static folder for CSP purposes.
We didn't bundle it to the main app since it would unnecessarily slow down initial rendering.
-->
<script src="%PUBLIC_URL%/static/scripts/mapbox/mapbox-sdk.min.js"></script>
<link href="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.css" rel="stylesheet" />
<script src="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.js"></script>
<script>
window.mapboxgl.accessToken = '%REACT_APP_MAPBOX_ACCESS_TOKEN%';
</script>
<!--
If Google Maps is used instead of Mapbox, you need to include Google's script instead:
<script src="https://maps.googleapis.com/maps/api/js?key=%REACT_APP_GOOGLE_MAPS_API_KEY%&libraries=places"></script>
-->
<!--
Stripe script should be on every page, not just the pages that
use the API:

View file

@ -0,0 +1,26 @@
Copyright (c) 2018, Mapbox
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation are those
of the authors and should not be interpreted as representing official policies,
either expressed or implied, of the FreeBSD Project.

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@ const self = "'self'";
const unsafeInline = "'unsafe-inline'";
const unsafeEval = "'unsafe-eval'";
const data = 'data:';
const blob = 'blob:';
const devImagesMaybe = dev ? ['*.localhost:8000'] : [];
// Default CSP whitelist.
@ -14,10 +15,13 @@ const devImagesMaybe = dev ? ['*.localhost:8000'] : [];
const defaultDirectives = {
baseUri: [self],
defaultSrc: [self],
childSrc: [blob],
connectSrc: [
self,
process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL,
'maps.googleapis.com',
'*.tiles.mapbox.com',
'api.mapbox.com',
// Google Analytics
'www.google-analytics.com',
@ -31,6 +35,7 @@ const defaultDirectives = {
imgSrc: [
self,
data,
blob,
...devImagesMaybe,
'*.imgix.net',
'sharetribe.imgix.net', // Safari 9.1 didn't recognize asterisk rule.
@ -39,6 +44,7 @@ const defaultDirectives = {
'lorempixel.com',
'via.placeholder.com',
'api.mapbox.com',
'maps.googleapis.com',
'*.gstatic.com',
'*.googleapis.com',
@ -57,10 +63,11 @@ const defaultDirectives = {
unsafeEval,
data,
'maps.googleapis.com',
'api.mapbox.com',
'*.google-analytics.com',
'js.stripe.com',
],
styleSrc: [self, unsafeInline, 'fonts.googleapis.com'],
styleSrc: [self, unsafeInline, 'fonts.googleapis.com', 'api.mapbox.com'],
};
/**

View file

@ -6,7 +6,26 @@ const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes;
export const CURRENT_LOCATION_ID = 'current-location';
const GENERATED_BOUNDS_DEFAULT_DISTANCE = 500; // meters
// Distances for generated bounding boxes for different Mapbox place types
const PLACE_TYPE_BOUNDS_DISTANCES = {
address: 500,
country: 2000,
region: 2000,
postcode: 2000,
district: 2000,
place: 2000,
locality: 2000,
neighborhood: 2000,
poi: 2000,
'poi.landmark': 2000,
};
const locationBounds = (latlng, distance) => {
if (!latlng) {
return null;
}
const bounds = new window.mapboxgl.LngLat(latlng.lng, latlng.lat).toBounds(distance);
return new SDKLatLngBounds(
new SDKLatLng(bounds.getNorth(), bounds.getEast()),
@ -23,12 +42,24 @@ const placeOrigin = prediction => {
};
const placeBounds = prediction => {
if (prediction && Array.isArray(prediction.bbox) && prediction.bbox.length === 4) {
// Bounds in Mapbox features are represented as [minX, minY, maxX, maxY]
return new SDKLatLngBounds(
new SDKLatLng(prediction.bbox[3], prediction.bbox[2]),
new SDKLatLng(prediction.bbox[1], prediction.bbox[0])
);
if (prediction) {
if (Array.isArray(prediction.bbox) && prediction.bbox.length === 4) {
// Bounds in Mapbox features are represented as [minX, minY, maxX, maxY]
return new SDKLatLngBounds(
new SDKLatLng(prediction.bbox[3], prediction.bbox[2]),
new SDKLatLng(prediction.bbox[1], prediction.bbox[0])
);
} else {
// If bounds are not available, generate them around the origin
// Resolve bounds distance based on place type
const placeType = Array.isArray(prediction.place_type) && prediction.place_type[0];
const distance =
(placeType && PLACE_TYPE_BOUNDS_DISTANCES[placeType]) || GENERATED_BOUNDS_DEFAULT_DISTANCE;
return locationBounds(placeOrigin(prediction), distance);
}
}
return null;
};

View file

@ -5,11 +5,12 @@ import classNames from 'classnames';
import debounce from 'lodash/debounce';
import { IconSpinner } from '../../components';
import { propTypes } from '../../util/types';
import config from '../../config';
import IconHourGlass from './IconHourGlass';
import IconCurrentLocation from './IconCurrentLocation';
import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderGoogleMaps';
// import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderMapbox';
import config from '../../config';
import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderMapbox';
// import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderGoogleMaps';
import css from './LocationAutocompleteInput.css';

View file

@ -1,5 +1,10 @@
import React, { Component } from 'react';
import { string, shape, number, object } from 'prop-types';
// This MultiTouch lib is used for 2-finger panning.
// which prevents user to experience map-scroll trap, while scrolling the page.
// https://github.com/mapbox/mapbox-gl-js/issues/2618
// TODO: we should add an overlay with text "use two fingers to pan".
import MultiTouch from 'mapbox-gl-multitouch';
import uniqueId from 'lodash/uniqueId';
import { circlePolyline } from '../../util/maps';
import config from '../../config';
@ -65,7 +70,8 @@ class DynamicMapboxMap extends Component {
zoom,
scrollZoom: false,
});
this.map.addControl(new window.mapboxgl.NavigationControl(), 'top-left');
this.map.addControl(new window.mapboxgl.NavigationControl({ showCompass: false }), 'top-left');
this.map.addControl(new MultiTouch());
if (mapsConfig.fuzzy.enabled) {
this.map.on('load', () => {

View file

@ -3,8 +3,8 @@ import { bool, number, object, string } from 'prop-types';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
import config from '../../config';
import { StaticMap, DynamicMap, isMapsLibLoaded } from './GoogleMap';
// import { StaticMap, DynamicMap, isMapsLibLoaded } from './MapboxMap';
import { StaticMap, DynamicMap, isMapsLibLoaded } from './MapboxMap';
// import { StaticMap, DynamicMap, isMapsLibLoaded } from './GoogleMap';
import css from './Map.css';

View file

@ -48,6 +48,7 @@ class ReusableMapContainer extends React.Component {
componentWillUnmount() {
this.el.classList.add(css.reusableMapHidden);
this.el.classList.add(this.props.reusableMapHiddenHandle);
this.mountNode.removeChild(this.el);
document.body.appendChild(this.el);
}
@ -90,6 +91,7 @@ class ReusableMapContainer extends React.Component {
renderChildren();
} else {
this.el.classList.remove(css.reusableMapHidden);
this.el.classList.remove(this.props.reusableMapHiddenHandle);
if (this.mountNode && !this.mountNode.firstChild) {
// Move the map to correct location if we have rendered the map before
@ -125,6 +127,7 @@ ReusableMapContainer.defaultProps = {
ReusableMapContainer.propTypes = {
children: node.isRequired,
className: string,
reusableMapHiddenHandle: string.isRequired,
};
export default ReusableMapContainer;

View file

@ -0,0 +1,35 @@
import groupBy from 'lodash/groupBy';
import reduce from 'lodash/reduce';
/**
* hasParentWithClassName searches class name from parent elements of given target
* @param {Node} target - element whose parent might contain given class.
* @param {String} className - class name string to be found
*/
export const hasParentWithClassName = (target, className) => {
return [...document.querySelectorAll(`.${className}`)].some(
el => el !== target && el.contains(target)
);
};
/**
* Listings array grouped by geolocation
* @param {Array} mapListings - listings to be grouped on map
* @return {Object} - Object where coordinate pair is the key to different listings
*/
export const groupedByCoordinates = mapListings => {
return groupBy(mapListings, l => {
const g = l.attributes.geolocation;
return `${g.lat}-${g.lng}`;
});
};
/**
* Listings (in location based object literal) is mapped to array
* @param {Object} mapListings - listings to be grouped on map
* @return {Array} - An array where items are arrays of listings
* (They are arrays containing all the listings in that location)
*/
export const reducedToArray = mapListings => {
return reduce(mapListings, (acc, listing) => acc.concat([listing]), []);
};

View file

@ -1,80 +1,27 @@
import React, { Component } from 'react';
import { arrayOf, bool, func, number, string, shape, object } from 'prop-types';
import { arrayOf, func, number, string, shape, object } from 'prop-types';
import { withRouter } from 'react-router-dom';
import { withGoogleMap, GoogleMap } from 'react-google-maps';
import classNames from 'classnames';
import groupBy from 'lodash/groupBy';
import isEqual from 'lodash/isEqual';
import reduce from 'lodash/reduce';
import routeConfiguration from '../../routeConfiguration';
import { createResourceLocatorString } from '../../util/routes';
import { createSlug } from '../../util/urlHelpers';
import { types as sdkTypes } from '../../util/sdkLoader';
import { propTypes } from '../../util/types';
import { obfuscatedCoordinates } from '../../util/maps';
import { googleBoundsToSDKBounds } from '../../util/googleMaps';
import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components';
import config from '../../config';
import { hasParentWithClassName } from './SearchMap.helpers.js';
import SearchMapWithMapbox, {
LABEL_HANDLE,
INFO_CARD_HANDLE,
getMapBounds,
getMapCenter,
fitMapToBounds,
isMapsLibLoaded,
} from './SearchMapWithMapbox';
import ReusableMapContainer from './ReusableMapContainer';
import css from './SearchMap.css';
const LABEL_HANDLE = 'SearchMapLabel';
const INFO_CARD_HANDLE = 'SearchMapInfoCard';
/**
* Fit part of map (descriped with bounds) to visible map-viewport
*
* @param {Object} map - map that needs to be centered with given bounds
* @param {SDK.LatLngBounds} bounds - the area that needs to be visible when map loads.
*/
const fitMapToBounds = (map, bounds, padding) => {
const { ne, sw } = bounds || {};
// map bounds as string literal for google.maps
const mapBounds = bounds ? { north: ne.lat, east: ne.lng, south: sw.lat, west: sw.lng } : null;
// If bounds are given, use it (defaults to center & zoom).
if (map && mapBounds) {
if (padding == null) {
map.fitBounds(mapBounds);
} else {
map.fitBounds(mapBounds, padding);
}
}
};
/**
* hasParentWithClassName searches class name from parent elements of given target
* @param {Node} target - element whose parent might contain given class.
* @param {String} className - class name string to be found
*/
const hasParentWithClassName = (target, className) => {
return [...document.querySelectorAll(`.${className}`)].some(
el => el !== target && el.contains(target)
);
};
/**
* Listings array grouped by geolocation
* @param {Array} mapListings - listings to be grouped on map
* @return {Object} - Object where coordinate pair is the key to different listings
*/
const groupedByCoordinates = mapListings => {
return groupBy(mapListings, l => {
const g = l.attributes.geolocation;
return `${g.lat}-${g.lng}`;
});
};
/**
* Listings (in location based object literal) is mapped to array
* @param {Object} mapListings - listings to be grouped on map
* @return {Array} - An array where items are arrays of listings
* (They are arrays containing all the listings in that location)
*/
const reducedToArray = mapListings => {
return reduce(mapListings, (acc, listing) => acc.concat([listing]), []);
};
const REUSABLE_MAP_HIDDEN_HANDLE = 'reusableMapHidden';
const withCoordinatesObfuscated = listings => {
return listings.map(listing => {
@ -93,116 +40,24 @@ const withCoordinatesObfuscated = listings => {
});
};
/**
* MapWithGoogleMap uses withGoogleMap HOC.
* It handles some of the google map initialization states.
*/
const MapWithGoogleMap = withGoogleMap(props => {
const {
activeListingId,
center,
infoCardOpen,
listings,
onIdle,
onListingClicked,
onListingInfoCardClicked,
createURLToListing,
onMapLoad,
zoom,
mapComponentRefreshToken,
} = props;
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
const isActive = activeListingId
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
: false;
// If location contains only one listing, print price label
if (listingArr.length === 1) {
const listing = listingArr[0];
const infoCardOpenIds = Array.isArray(infoCardOpen) ? infoCardOpen.map(l => l.id.uuid) : [];
// if the listing is open, don't print price label
if (infoCardOpen != null && infoCardOpenIds.includes(listing.id.uuid)) {
return null;
}
return (
<SearchMapPriceLabel
isActive={isActive}
key={listing.id.uuid}
className={LABEL_HANDLE}
listing={listing}
onListingClicked={onListingClicked}
mapComponentRefreshToken={mapComponentRefreshToken}
/>
);
}
return (
<SearchMapGroupLabel
isActive={isActive}
key={listingArr[0].id.uuid}
className={LABEL_HANDLE}
listings={listingArr}
onListingClicked={onListingClicked}
mapComponentRefreshToken={mapComponentRefreshToken}
/>
);
});
const listingsArray = Array.isArray(infoCardOpen) ? infoCardOpen : [infoCardOpen];
const openedCard = infoCardOpen ? (
<SearchMapInfoCard
key={listingsArray[0].id.uuid}
mapComponentRefreshToken={mapComponentRefreshToken}
className={INFO_CARD_HANDLE}
listings={listingsArray}
onListingInfoCardClicked={onListingInfoCardClicked}
createURLToListing={createURLToListing}
/>
) : null;
const controlPosition =
typeof window !== 'undefined' && typeof window.google !== 'undefined'
? window.google.maps.ControlPosition.LEFT_TOP
: 5;
return (
<GoogleMap
defaultZoom={zoom}
defaultCenter={center}
options={{
// Disable all controls except zoom
mapTypeControl: false,
scrollwheel: false,
fullscreenControl: false,
clickableIcons: false,
streetViewControl: false,
// When infoCard is open, we can't differentiate double click on top of card vs map.
disableDoubleClickZoom: !!infoCardOpen,
zoomControlOptions: {
position: controlPosition,
},
}}
ref={onMapLoad}
onIdle={onIdle}
>
{priceLabels}
{openedCard}
</GoogleMap>
);
});
export class SearchMapComponent extends Component {
constructor(props) {
super(props);
this.listings = [];
this.googleMap = null;
this.mapReattachmentCount = 0;
this.state = { infoCardOpen: null };
this.mapRef = null;
let mapReattachmentCount = 0;
if (typeof window !== 'undefined') {
if (window.mapReattachmentCount) {
mapReattachmentCount = window.mapReattachmentCount;
} else {
window.mapReattachmentCount = 0;
}
}
this.state = { infoCardOpen: null, mapReattachmentCount };
this.createURLToListing = this.createURLToListing.bind(this);
this.onListingInfoCardClicked = this.onListingInfoCardClicked.bind(this);
@ -211,19 +66,6 @@ export class SearchMapComponent extends Component {
this.onMapLoadHandler = this.onMapLoadHandler.bind(this);
}
componentWillReceiveProps(nextProps) {
if (this.googleMap) {
const currentBounds = googleBoundsToSDKBounds(this.googleMap.getBounds());
// Do not call fitMapToBounds if bounds are the same.
// Our bounds are viewport bounds, and fitBounds will try to add margins around those bounds
// that would result to zoom-loop (bound change -> fitmap -> bounds change -> ...)
if (!isEqual(nextProps.bounds, currentBounds) && nextProps.useLocationSearchBounds) {
fitMapToBounds(this.googleMap, nextProps.bounds, 0);
}
}
}
componentWillUnmount() {
this.listings = [];
}
@ -262,11 +104,11 @@ export class SearchMapComponent extends Component {
}
onMapLoadHandler(map) {
this.googleMap = map;
this.mapRef = map;
if (this.googleMap) {
if (this.mapRef && this.state.mapReattachmentCount === 0) {
// map is ready, let's fit search area's bounds to map's viewport
fitMapToBounds(this.googleMap, this.props.bounds, 0);
fitMapToBounds(this.mapRef, this.props.bounds, { padding: 0, isAutocompleteSearch: true });
}
}
@ -275,17 +117,16 @@ export class SearchMapComponent extends Component {
className,
rootClassName,
reusableContainerClassName,
bounds,
center,
location,
listings: originalListings,
mapRootClassName,
onCloseAsModal,
onIdle,
onMapMoveEnd,
zoom,
mapsConfig,
activeListingId,
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const mapClasses = mapRootClassName || css.mapRoot;
const listingsWithLocation = originalListings.filter(l => !!l.attributes.geolocation);
const listings = mapsConfig.fuzzy.enabled
@ -293,45 +134,64 @@ export class SearchMapComponent extends Component {
: listingsWithLocation;
const infoCardOpen = this.state.infoCardOpen;
const isMapsLibLoaded = typeof window !== 'undefined' && window.google && window.google.maps;
const forceUpdateHandler = () => {
this.mapReattachmentCount += 1;
// Update global reattachement count
window.mapReattachmentCount += 1;
// Initiate rerendering
this.setState({ mapReattachmentCount: window.mapReattachmentCount });
};
// container element listens clicks so that opened SearchMapInfoCard can be closed
/* eslint-disable jsx-a11y/no-static-element-interactions */
return isMapsLibLoaded ? (
<ReusableMapContainer className={reusableContainerClassName} onReattach={forceUpdateHandler}>
<MapWithGoogleMap
containerElement={<div className={classes} onClick={this.onMapClicked} />}
mapElement={<div className={mapClasses} />}
// When changing from default map provider to Google Maps, you should use the following
// component instead of SearchMapWithMapbox:
//
// <SearchMapWithGoogleMap
// containerElement={
// <div id="search-map-container" className={classes} onClick={this.onMapClicked} />
// }
// mapElement={<div className={mapRootClassName || css.mapRoot} />}
// bounds={bounds}
// center={center}
// location={location}
// infoCardOpen={infoCardOpen}
// listings={listings}
// activeListingId={activeListingId}
// mapComponentRefreshToken={this.state.mapReattachmentCount}
// createURLToListing={this.createURLToListing}
// onListingClicked={this.onListingClicked}
// onListingInfoCardClicked={this.onListingInfoCardClicked}
// onMapLoad={this.onMapLoadHandler}
// onMapMoveEnd={onMapMoveEnd}
// zoom={zoom}
// />
return isMapsLibLoaded() ? (
<ReusableMapContainer
className={reusableContainerClassName}
reusableMapHiddenHandle={REUSABLE_MAP_HIDDEN_HANDLE}
onReattach={forceUpdateHandler}
>
<SearchMapWithMapbox
className={classes}
bounds={bounds}
center={center}
location={location}
infoCardOpen={infoCardOpen}
listings={listings}
activeListingId={activeListingId}
infoCardOpen={infoCardOpen}
mapComponentRefreshToken={this.state.mapReattachmentCount}
createURLToListing={this.createURLToListing}
onListingClicked={this.onListingClicked}
onListingInfoCardClicked={this.onListingInfoCardClicked}
createURLToListing={this.createURLToListing}
onMapLoad={this.onMapLoadHandler}
onIdle={() => {
if (this.googleMap) {
onIdle(this.googleMap);
}
}}
onCloseAsModal={() => {
if (onCloseAsModal) {
onCloseAsModal();
}
}}
onClick={this.onMapClicked}
onMapMoveEnd={onMapMoveEnd}
zoom={zoom}
mapComponentRefreshToken={this.mapReattachmentCount}
reusableMapHiddenHandle={REUSABLE_MAP_HIDDEN_HANDLE}
/>
</ReusableMapContainer>
) : (
<div className={classes} />
);
/* eslint-enable jsx-a11y/no-static-element-interactions */
}
}
@ -341,11 +201,10 @@ SearchMapComponent.defaultProps = {
mapRootClassName: null,
reusableContainerClassName: null,
bounds: null,
center: new sdkTypes.LatLng(0, 0),
center: null,
activeListingId: null,
listings: [],
onCloseAsModal: null,
useLocationSearchBounds: true,
zoom: 11,
mapsConfig: config.maps,
};
@ -357,11 +216,13 @@ SearchMapComponent.propTypes = {
reusableContainerClassName: string,
bounds: propTypes.latlngBounds,
center: propTypes.latlng,
location: shape({
search: string.isRequired,
}).isRequired,
activeListingId: propTypes.uuid,
listings: arrayOf(propTypes.listing),
onCloseAsModal: func,
onIdle: func.isRequired,
useLocationSearchBounds: bool, // eslint-disable-line react/no-unused-prop-types
onMapMoveEnd: func.isRequired,
zoom: number,
mapsConfig: object,
@ -373,4 +234,7 @@ SearchMapComponent.propTypes = {
const SearchMap = withRouter(SearchMapComponent);
SearchMap.getMapBounds = getMapBounds;
SearchMap.getMapCenter = getMapCenter;
export default SearchMap;

View file

@ -0,0 +1,463 @@
import React, { Component } from 'react';
import { arrayOf, func, node, number, oneOfType, shape, string } from 'prop-types';
import isEqual from 'lodash/isEqual';
import { withGoogleMap, GoogleMap, OverlayView } from 'react-google-maps';
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
import { types as sdkTypes } from '../../util/sdkLoader';
import { parse } from '../../util/urlHelpers';
import { propTypes } from '../../util/types';
import { ensureListing } from '../../util/data';
import { sdkBoundsToFixedCoordinates, hasSameSDKBounds } from '../../util/maps';
import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components';
import { groupedByCoordinates, reducedToArray } from './SearchMap.helpers.js';
export const LABEL_HANDLE = 'SearchMapLabel';
export const INFO_CARD_HANDLE = 'SearchMapInfoCard';
const BOUNDS_FIXED_PRECISION = 8;
const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes;
/**
* Fit part of map (descriped with bounds) to visible map-viewport
*
* @param {Object} map - map that needs to be centered with given bounds
* @param {SDK.LatLngBounds} bounds - the area that needs to be visible when map loads.
*/
export const fitMapToBounds = (map, bounds, options) => {
const { padding } = options;
const { ne, sw } = bounds || {};
// map bounds as string literal for google.maps
const mapBounds = bounds ? { north: ne.lat, east: ne.lng, south: sw.lat, west: sw.lng } : null;
// If bounds are given, use it (defaults to center & zoom).
if (map && mapBounds) {
if (padding == null) {
map.fitBounds(mapBounds);
} else {
map.fitBounds(mapBounds, padding);
}
}
};
/**
* Convert Google formatted LatLng object to Sharetribe SDK's LatLng coordinate format
*
* @param {LatLng} googleLatLng - Google Maps LatLng
*
* @return {SDKLatLng} - Converted latLng coordinate
*/
export const googleLatLngToSDKLatLng = googleLatLng => {
if (!googleLatLng) {
return null;
}
return new SDKLatLng(googleLatLng.lat(), googleLatLng.lng());
};
/**
* Convert Google formatted bounds object to Sharetribe SDK's bounds format
*
* @param {LatLngBounds} googleBounds - Google Maps LatLngBounds
*
* @return {SDKLatLngBounds} - Converted bounds
*/
export const googleBoundsToSDKBounds = googleBounds => {
if (!googleBounds) {
return null;
}
const ne = googleBounds.getNorthEast();
const sw = googleBounds.getSouthWest();
return new SDKLatLngBounds(new SDKLatLng(ne.lat(), ne.lng()), new SDKLatLng(sw.lat(), sw.lng()));
};
export const getMapBounds = map => googleBoundsToSDKBounds(map.getBounds());
export const getMapCenter = map => googleLatLngToSDKLatLng(map.getCenter());
/**
* Check if map library is loaded
*/
export const isMapsLibLoaded = () =>
typeof window !== 'undefined' && window.google && window.google.maps;
/**
* FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
* Override draw function to catch errors with map panes being undefined to prevent console errors
* https://github.com/tomchentw/react-google-maps/issues/482
*/
class CustomOverlayView extends OverlayView {
draw() {
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
// Add conditional to ensure panes and container exist before drawing
if (mapPanes && this.containerElement) {
super.draw();
}
}
}
/**
* Center label so that caret is pointing to correct pixel.
* (vertical positioning: height + arrow)
*/
const getPixelPositionOffset = (width, height) => {
return { x: -1 * (width / 2), y: -1 * (height + 3) };
};
/**
* GoogleMaps need to use Google specific OverlayView components and therefore we need to
* reduce flickering / rerendering of these overlays through 'shouldComponentUpdate'
*/
class SearchMapPriceLabelWithOverlay extends Component {
shouldComponentUpdate(nextProps) {
const currentListing = ensureListing(this.props.listing);
const nextListing = ensureListing(nextProps.listing);
const isSameListing = currentListing.id.uuid === nextListing.id.uuid;
const hasSamePrice = currentListing.attributes.price === nextListing.attributes.price;
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
const hasSameRefreshToken =
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
return !(isSameListing && hasSamePrice && hasSameActiveStatus && hasSameRefreshToken);
}
render() {
const {
position,
mapPaneName,
isActive,
className,
listing,
onListingClicked,
mapComponentRefreshToken,
} = this.props;
return (
<CustomOverlayView
position={position}
mapPaneName={mapPaneName}
getPixelPositionOffset={getPixelPositionOffset}
>
<SearchMapPriceLabel
isActive={isActive}
className={className}
listing={listing}
onListingClicked={onListingClicked}
mapComponentRefreshToken={mapComponentRefreshToken}
/>
</CustomOverlayView>
);
}
}
/**
* GoogleMaps need to use Google specific OverlayView components and therefore we need to
* reduce flickering / rerendering of these overlays through 'shouldComponentUpdate'
*/
class SearchMapGroupLabelWithOverlay extends Component {
shouldComponentUpdate(nextProps) {
const hasSameAmountOfListings = nextProps.listings.length === this.props.listings.length;
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
const hasSameRefreshToken =
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
return !(hasSameAmountOfListings && hasSameActiveStatus && hasSameRefreshToken);
}
render() {
const {
position,
mapPaneName,
isActive,
className,
listings,
onListingClicked,
mapComponentRefreshToken,
} = this.props;
return (
<CustomOverlayView
position={position}
mapPaneName={mapPaneName}
getPixelPositionOffset={getPixelPositionOffset}
>
<SearchMapGroupLabel
isActive={isActive}
className={className}
listings={listings}
onListingClicked={onListingClicked}
mapComponentRefreshToken={mapComponentRefreshToken}
/>
</CustomOverlayView>
);
}
}
const priceLabelsInLocations = (
listings,
activeListingId,
infoCardOpen,
onListingClicked,
mapComponentRefreshToken
) => {
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
const isActive = activeListingId
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
: false;
// If location contains only one listing, print price label
if (listingArr.length === 1) {
const listing = listingArr[0];
const infoCardOpenIds = Array.isArray(infoCardOpen) ? infoCardOpen.map(l => l.id.uuid) : [];
// if the listing is open, don't print price label
if (infoCardOpen != null && infoCardOpenIds.includes(listing.id.uuid)) {
return null;
}
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const { geolocation } = listing.attributes;
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
return (
<SearchMapPriceLabelWithOverlay
key={listing.id.uuid}
position={latLngLiteral}
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
isActive={isActive}
className={LABEL_HANDLE}
listing={listing}
onListingClicked={onListingClicked}
mapComponentRefreshToken={mapComponentRefreshToken}
/>
);
}
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const firstListing = ensureListing(listingArr[0]);
const geolocation = firstListing.attributes.geolocation;
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
return (
<SearchMapGroupLabelWithOverlay
key={listingArr[0].id.uuid}
position={latLngLiteral}
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
isActive={isActive}
className={LABEL_HANDLE}
listings={listingArr}
onListingClicked={onListingClicked}
mapComponentRefreshToken={mapComponentRefreshToken}
/>
);
});
return priceLabels;
};
const infoCardComponent = (
infoCardOpen,
onListingInfoCardClicked,
createURLToListing,
mapComponentRefreshToken
) => {
const listingsArray = Array.isArray(infoCardOpen) ? infoCardOpen : [infoCardOpen];
if (!infoCardOpen) {
return null;
}
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const firstListing = ensureListing(listingsArray[0]);
const geolocation = firstListing.attributes.geolocation;
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
return (
<CustomOverlayView
key={listingsArray[0].id.uuid}
position={latLngLiteral}
mapPaneName={OverlayView.FLOAT_PANE}
getPixelPositionOffset={getPixelPositionOffset}
styles={{ zIndex: 1 }}
>
<SearchMapInfoCard
mapComponentRefreshToken={mapComponentRefreshToken}
className={INFO_CARD_HANDLE}
listings={listingsArray}
onListingInfoCardClicked={onListingInfoCardClicked}
createURLToListing={createURLToListing}
/>
</CustomOverlayView>
);
};
/**
* MapWithGoogleMap uses withGoogleMap HOC.
* It handles some of the google map initialization states.
*/
const MapWithGoogleMap = withGoogleMap(props => {
const {
center,
infoCardOpen,
listings,
activeListingId,
createURLToListing,
onListingClicked,
onListingInfoCardClicked,
onIdle,
onMapLoad,
zoom,
mapComponentRefreshToken,
} = props;
const controlPosition =
typeof window !== 'undefined' && typeof window.google !== 'undefined'
? window.google.maps.ControlPosition.LEFT_TOP
: 5;
const priceLabels = priceLabelsInLocations(
listings,
activeListingId,
infoCardOpen,
onListingClicked,
mapComponentRefreshToken
);
const infoCard = infoCardComponent(
infoCardOpen,
onListingInfoCardClicked,
createURLToListing,
mapComponentRefreshToken
);
return (
<GoogleMap
defaultZoom={zoom}
defaultCenter={center}
options={{
// Disable all controls except zoom
mapTypeControl: false,
scrollwheel: false,
fullscreenControl: false,
clickableIcons: false,
streetViewControl: false,
// When infoCard is open, we can't differentiate double click on top of card vs map.
disableDoubleClickZoom: !!infoCardOpen,
zoomControlOptions: {
position: controlPosition,
},
}}
ref={onMapLoad}
onIdle={onIdle}
>
{priceLabels}
{infoCard}
</GoogleMap>
);
});
class SearchMapWithGoogleMap extends Component {
constructor(props) {
super(props);
this.map = null;
this.viewportBounds = null;
this.onMapLoad = this.onMapLoad.bind(this);
this.onIdle = this.onIdle.bind(this);
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.location, nextProps.location)) {
// If no mapSearch url parameter is given, this is original location search
const { mapSearch } = parse(nextProps.location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
if (!mapSearch) {
this.viewportBounds = null;
}
}
if (this.map) {
const currentBounds = getMapBounds(this.map);
// Do not call fitMapToBounds if bounds are the same.
// Our bounds are viewport bounds, and fitBounds will try to add margins around those bounds
// that would result to zoom-loop (bound change -> fitmap -> bounds change -> ...)
if (!isEqual(nextProps.bounds, currentBounds) && !this.viewportBounds) {
fitMapToBounds(this.map, nextProps.bounds, { padding: 0 });
}
}
}
onMapLoad(map) {
this.map = map;
this.props.onMapLoad(map);
}
onIdle(e) {
if (this.map) {
// Let's try to find the map container element
const mapContainer = this.props.containerElement.props.id
? document.getElementById(this.props.containerElement.props.id)
: null;
// If reusableMapHiddenHandle is given and parent element has that class,
// we don't listen moveend events.
// This fixes mobile Chrome bug that sends map events to invisible map components.
const isHiddenByReusableMap =
this.props.reusableMapHiddenHandle &&
mapContainer &&
mapContainer.parentElement.classList.contains(this.props.reusableMapHiddenHandle);
if (!isHiddenByReusableMap) {
const viewportMapBounds = getMapBounds(this.map);
const viewportMapCenter = getMapCenter(this.map);
const viewportBounds = viewportMapBounds
? sdkBoundsToFixedCoordinates(viewportMapBounds, BOUNDS_FIXED_PRECISION)
: null;
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
const viewportBoundsChanged =
this.viewportBounds &&
viewportBounds &&
!hasSameSDKBounds(this.viewportBounds, viewportBounds);
this.props.onMapMoveEnd(viewportBoundsChanged, { viewportBounds, viewportMapCenter });
this.viewportBounds = viewportBounds;
}
}
}
render() {
const { onMapLoad, onMapMoveEnd, ...rest } = this.props;
return <MapWithGoogleMap onMapLoad={this.onMapLoad} onIdle={this.onIdle} {...rest} />;
}
}
SearchMapWithGoogleMap.defaultProps = {
center: new sdkTypes.LatLng(0, 0),
infoCardOpen: null,
listings: [],
activeListingId: null,
zoom: 11,
reusableMapHiddenHandle: null,
};
SearchMapWithGoogleMap.propTypes = {
containerElement: node.isRequired,
center: propTypes.latlng,
location: shape({
search: string.isRequired,
}).isRequired,
infoCardOpen: oneOfType([propTypes.listing, arrayOf(propTypes.listing)]),
listings: arrayOf(propTypes.listing),
activeListingId: propTypes.uuid,
onMapMoveEnd: func.isRequired,
onMapLoad: func.isRequired,
zoom: number,
reusableMapHiddenHandle: string,
};
export default SearchMapWithGoogleMap;

View file

@ -0,0 +1,25 @@
.fullArea {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.activeLabel {
z-index: 1;
}
.labelContainer {
&:hover {
z-index: 1;
}
&:focus {
outline: none;
}
}
.infoCardContainer {
z-index: 1;
}

View file

@ -0,0 +1,518 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { arrayOf, func, node, number, shape, string } from 'prop-types';
import differenceBy from 'lodash/differenceBy';
import isEqual from 'lodash/isEqual';
import classNames from 'classnames';
import { types as sdkTypes } from '../../util/sdkLoader';
import { parse } from '../../util/urlHelpers';
import { propTypes } from '../../util/types';
import { ensureListing } from '../../util/data';
import { sdkBoundsToFixedCoordinates, hasSameSDKBounds } from '../../util/maps';
import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components';
import { groupedByCoordinates, reducedToArray } from './SearchMap.helpers.js';
import css from './SearchMapWithMapbox.css';
export const LABEL_HANDLE = 'SearchMapLabel';
export const INFO_CARD_HANDLE = 'SearchMapInfoCard';
export const SOURCE_AUTOCOMPLETE = 'autocomplete';
const BOUNDS_FIXED_PRECISION = 8;
const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes;
/**
* Fit part of map (descriped with bounds) to visible map-viewport
*
* @param {Object} map - map that needs to be centered with given bounds
* @param {SDK.LatLngBounds} bounds - the area that needs to be visible when map loads.
*/
export const fitMapToBounds = (map, bounds, options) => {
const { padding = 0, isAutocompleteSearch = false } = options;
// map bounds as string literal for google.maps
const mapBounds = sdkBoundsToMapboxBounds(bounds);
const paddingOptionMaybe = padding == null ? { padding } : {};
const eventData = isAutocompleteSearch ? { searchSource: SOURCE_AUTOCOMPLETE } : {};
// If bounds are given, use it (defaults to center & zoom).
if (map && mapBounds) {
map.fitBounds(mapBounds, { ...paddingOptionMaybe, linear: true, duration: 0 }, eventData);
}
};
/**
* Convert Mapbox formatted LatLng object to Sharetribe SDK's LatLng coordinate format
* Longitudes > 180 and < -180 are converted to the correct corresponding value
* between -180 and 180.
*
* @param {LngLat} mapboxLngLat - Mapbox LngLat
*
* @return {SDKLatLng} - Converted latLng coordinate
*/
export const mapboxLngLatToSDKLatLng = lngLat => {
const mapboxLng = lngLat.lng;
// For bounding boxes that overlap the antimeridian Mapbox sometimes gives
// longitude values outside -180 and 180 degrees.Those values are converted
// so that longitude is always between -180 and 180.
const lng = mapboxLng > 180 ? mapboxLng - 360 : mapboxLng < -180 ? mapboxLng + 360 : mapboxLng;
return new SDKLatLng(lngLat.lat, lng);
};
/**
* Convert Mapbox formatted bounds object to Sharetribe SDK's bounds format
*
* @param {LngLatBounds} mapboxBounds - Mapbox LngLatBounds
*
* @return {SDKLatLngBounds} - Converted bounds
*/
export const mapboxBoundsToSDKBounds = mapboxBounds => {
if (!mapboxBounds) {
return null;
}
const ne = mapboxBounds.getNorthEast();
const sw = mapboxBounds.getSouthWest();
return new SDKLatLngBounds(mapboxLngLatToSDKLatLng(ne), mapboxLngLatToSDKLatLng(sw));
};
/**
* Convert sdk bounds that overlap the antimeridian into values that can
* be passed to Mapbox. This is achieved by converting the SW longitude into
* a value less than -180 that flows over the antimeridian.
*
* @param {SDKLatLng} bounds - bounds passed to the map
*
* @return {LngLatBoundsLike} a bounding box that is compatible with Mapbox
*/
const sdkBoundsToMapboxBounds = bounds => {
if (!bounds) {
return null;
}
const { ne, sw } = bounds;
// if sw lng is > ne lng => the bounds overlap antimeridian
// => flip the nw lng to the negative side so that the value
// is less than -180
const swLng = sw.lng > ne.lng ? -360 + sw.lng : sw.lng;
return [[swLng, sw.lat], [ne.lng, ne.lat]];
};
/**
* Return map bounds as SDKBounds
*
* @param {Mapbox} map - Mapbox map from where the bounds are asked
*
* @return {SDKLatLngBounds} - Converted bounds of given map
*/
export const getMapBounds = map => mapboxBoundsToSDKBounds(map.getBounds());
/**
* Return map center as SDKLatLng
*
* @param {Mapbox} map - Mapbox map from where the center is asked
*
* @return {SDKLatLng} - Converted center of given map
*/
export const getMapCenter = map => mapboxLngLatToSDKLatLng(map.getCenter());
/**
* Check if map library is loaded
*/
export const isMapsLibLoaded = () =>
typeof window !== 'undefined' && window.mapboxgl && window.mapboxgl.accessToken;
/**
* Return price labels grouped by listing locations.
* This is a helper function for SearchMapWithMapbox component.
*/
const priceLabelsInLocations = (
listings,
activeListingId,
infoCardOpen,
onListingClicked,
mapComponentRefreshToken
) => {
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
const isActive = activeListingId
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
: false;
// If location contains only one listing, print price label
if (listingArr.length === 1) {
const listing = listingArr[0];
const infoCardOpenIds = Array.isArray(infoCardOpen)
? infoCardOpen.map(l => l.id.uuid)
: infoCardOpen
? [infoCardOpen.id.uuid]
: [];
// if the listing is open, don't print price label
if (infoCardOpen != null && infoCardOpenIds.includes(listing.id.uuid)) {
return null;
}
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const { geolocation } = listing.attributes;
const key = listing.id.uuid;
return {
markerId: `price_${key}`,
location: geolocation,
type: 'price',
componentProps: {
key,
isActive,
className: LABEL_HANDLE,
listing,
onListingClicked,
mapComponentRefreshToken,
},
};
}
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const firstListing = ensureListing(listingArr[0]);
const geolocation = firstListing.attributes.geolocation;
const key = listingArr[0].id.uuid;
return {
markerId: `group_${key}`,
location: geolocation,
type: 'group',
componentProps: {
key,
isActive,
className: LABEL_HANDLE,
listings: listingArr,
onListingClicked,
mapComponentRefreshToken,
},
};
});
return priceLabels;
};
/**
* Return info card. This is a helper function for SearchMapWithMapbox component.
*/
const infoCardComponent = (
infoCardOpen,
onListingInfoCardClicked,
createURLToListing,
mapComponentRefreshToken
) => {
const listingsArray = Array.isArray(infoCardOpen) ? infoCardOpen : [infoCardOpen];
if (!infoCardOpen) {
return null;
}
const firstListing = ensureListing(listingsArray[0]);
const key = firstListing.id.uuid;
const geolocation = firstListing.attributes.geolocation;
return {
markerId: `infoCard_${key}`,
location: geolocation,
componentProps: {
key,
mapComponentRefreshToken,
className: INFO_CARD_HANDLE,
listings: listingsArray,
onListingInfoCardClicked,
createURLToListing,
},
};
};
/**
* SearchMap component using Mapbox as map provider
*/
class SearchMapWithMapbox extends Component {
constructor(props) {
super(props);
this.map = typeof window !== 'undefined' && window.mapboxMap ? window.mapboxMap : null;
this.currentMarkers = [];
this.currentInfoCard = null;
this.state = { mapContainer: null, isMapReady: false };
this.viewportBounds = null;
this.onMount = this.onMount.bind(this);
this.onMoveend = this.onMoveend.bind(this);
this.initializeMap = this.initializeMap.bind(this);
this.handleDoubleClickOnInfoCard = this.handleDoubleClickOnInfoCard.bind(this);
this.handleMobilePinchZoom = this.handleMobilePinchZoom.bind(this);
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.location, nextProps.location)) {
// If no mapSearch url parameter is given, this is original location search
const { mapSearch } = parse(nextProps.location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
if (!mapSearch) {
this.viewportBounds = null;
}
}
if (this.map) {
const currentBounds = getMapBounds(this.map);
// Do not call fitMapToBounds if bounds are the same.
// Our bounds are viewport bounds, and fitBounds will try to add margins around those bounds
// that would result to zoom-loop (bound change -> fitmap -> bounds change -> ...)
if (!isEqual(nextProps.bounds, currentBounds) && !this.viewportBounds) {
fitMapToBounds(this.map, nextProps.bounds, { padding: 0, isAutocompleteSearch: true });
}
}
}
componentDidUpdate(prevProps) {
if (!this.map && this.state.mapContainer) {
this.initializeMap();
/* Notify parent component that Mapbox map is loaded */
this.props.onMapLoad(this.map);
} else if (prevProps.mapComponentRefreshToken !== this.props.mapComponentRefreshToken) {
/* Notify parent component that Mapbox map is loaded */
this.props.onMapLoad(this.map);
}
}
componentWillUnmount() {
this.currentInfoCard.markerContainer.removeEventListener(
'dblclick',
this.handleDoubleClickOnInfoCard
);
document.removeEventListener('gesturestart', this.handleMobilePinchZoom, false);
document.removeEventListener('gesturechange', this.handleMobilePinchZoom, false);
document.removeEventListener('gestureend', this.handleMobilePinchZoom, false);
}
onMount(element) {
// This prevents pinch zoom to affect whole page on mobile Safari.
document.addEventListener('gesturestart', this.handleMobilePinchZoom, false);
document.addEventListener('gesturechange', this.handleMobilePinchZoom, false);
document.addEventListener('gestureend', this.handleMobilePinchZoom, false);
this.setState({ mapContainer: element });
}
onMoveend(e) {
if (this.map) {
// If reusableMapHiddenHandle is given and parent element has that class,
// we don't listen moveend events.
// This fixes mobile Chrome bug that sends map events to invisible map components.
const isHiddenByReusableMap =
this.props.reusableMapHiddenHandle &&
this.state.mapContainer.parentElement.classList.contains(
this.props.reusableMapHiddenHandle
);
if (!isHiddenByReusableMap) {
const viewportMapBounds = getMapBounds(this.map);
const viewportMapCenter = getMapCenter(this.map);
const viewportBounds = sdkBoundsToFixedCoordinates(
viewportMapBounds,
BOUNDS_FIXED_PRECISION
);
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
const viewportBoundsChanged =
this.viewportBounds && !hasSameSDKBounds(this.viewportBounds, viewportBounds);
this.props.onMapMoveEnd(viewportBoundsChanged, { viewportBounds, viewportMapCenter });
this.viewportBounds = viewportBounds;
}
}
}
initializeMap() {
const { offsetHeight, offsetWidth } = this.state.mapContainer;
const hasDimensions = offsetHeight > 0 && offsetWidth > 0;
if (hasDimensions) {
this.map = new window.mapboxgl.Map({
container: this.state.mapContainer,
style: 'mapbox://styles/mapbox/streets-v10',
scrollZoom: false,
});
window.mapboxMap = this.map;
var nav = new window.mapboxgl.NavigationControl({ showCompass: false });
this.map.addControl(nav, 'top-left');
this.map.on('moveend', this.onMoveend);
// Introduce rerendering after map is ready (to include labels),
// but keep the map out of state life cycle.
this.setState({ isMapReady: true });
}
}
handleMobilePinchZoom(e) {
e.preventDefault();
// A hack to prevent pinch zoom gesture in mobile Safari
// Otherwise, pinch zoom would zoom both map and the document.
document.body.style.zoom = 0.99;
}
handleDoubleClickOnInfoCard(e) {
e.stopPropagation();
}
render() {
const {
className,
listings,
activeListingId,
infoCardOpen,
onListingClicked,
onListingInfoCardClicked,
createURLToListing,
mapComponentRefreshToken,
} = this.props;
if (this.map) {
// Create markers out of price labels and grouped labels
const labels = priceLabelsInLocations(
listings,
activeListingId,
infoCardOpen,
onListingClicked,
mapComponentRefreshToken
);
// If map has moved or info card opened, unnecessary markers need to be removed
const removableMarkers = differenceBy(this.currentMarkers, labels, 'markerId');
removableMarkers.forEach(rm => rm.marker.remove());
// Helper function to create markers to given container
const createMarker = (data, markerContainer) =>
new window.mapboxgl.Marker(markerContainer, { anchor: 'bottom' })
.setLngLat([data.location.lng, data.location.lat])
.addTo(this.map);
// SearchMapPriceLabel and SearchMapGroupLabel:
// create a new marker or use existing one if markerId is among previously rendered markers
this.currentMarkers = labels.filter(v => v != null).map(m => {
const existingMarkerId = this.currentMarkers.findIndex(
marker => m.markerId === marker.markerId && marker.marker
);
if (existingMarkerId >= 0) {
const { marker, markerContainer, ...rest } = this.currentMarkers[existingMarkerId];
return { ...rest, ...m, markerContainer, marker };
} else {
const markerContainer = document.createElement('div');
markerContainer.setAttribute('id', m.markerId);
markerContainer.classList.add(css.labelContainer);
const marker = createMarker(m, markerContainer);
return { ...m, markerContainer, marker };
}
});
/* Create marker for SearchMapInfoCard component */
if (infoCardOpen) {
const infoCard = infoCardComponent(
infoCardOpen,
onListingInfoCardClicked,
createURLToListing,
mapComponentRefreshToken
);
// marker container and its styles
const infoCardContainer = document.createElement('div');
infoCardContainer.setAttribute('id', infoCard.markerId);
infoCardContainer.classList.add(css.infoCardContainer);
infoCardContainer.addEventListener('dblclick', this.handleDoubleClickOnInfoCard, false);
this.currentInfoCard = {
...infoCard,
markerContainer: infoCardContainer,
marker: infoCard ? createMarker(infoCard, infoCardContainer) : null,
};
} else {
if (this.currentInfoCard) {
this.currentInfoCard.markerContainer.removeEventListener(
'dblclick',
this.handleDoubleClickOnInfoCard
);
}
this.currentInfoCard = null;
}
}
return (
<div
id="map"
ref={this.onMount}
className={classNames(className, css.fullArea)}
onClick={this.props.onClick}
>
{this.currentMarkers.map(m => {
// Remove existing activeLabel classes and add it only to the correct container
m.markerContainer.classList.remove(css.activeLabel);
if (activeListingId && activeListingId.uuid === m.componentProps.key) {
m.markerContainer.classList.add(css.activeLabel);
}
const isMapReadyForMarkers = this.map && m.markerContainer;
// DOM node that should be used as portal's root
const portalDOMContainer = isMapReadyForMarkers
? document.getElementById(m.markerContainer.id)
: null;
// Create component portals for correct marker containers
if (isMapReadyForMarkers && m.type === 'price') {
return ReactDOM.createPortal(
<SearchMapPriceLabel {...m.componentProps} />,
portalDOMContainer
);
} else if (isMapReadyForMarkers && m.type === 'group') {
return ReactDOM.createPortal(
<SearchMapGroupLabel {...m.componentProps} />,
portalDOMContainer
);
}
return null;
})}
{this.state.mapContainer && this.currentInfoCard
? ReactDOM.createPortal(
<SearchMapInfoCard {...this.currentInfoCard.componentProps} />,
this.currentInfoCard.markerContainer
)
: null}
</div>
);
}
}
SearchMapWithMapbox.defaultProps = {
center: null,
priceLabels: [],
infoCard: null,
zoom: 11,
reusableMapHiddenHandle: null,
};
SearchMapWithMapbox.propTypes = {
center: propTypes.latlng,
location: shape({
search: string.isRequired,
}).isRequired,
priceLabels: arrayOf(node),
infoCard: node,
onClick: func.isRequired,
onMapMoveEnd: func.isRequired,
onMapLoad: func.isRequired,
zoom: number,
reusableMapHiddenHandle: string,
};
export default SearchMapWithMapbox;

View file

@ -1,64 +1,32 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { OverlayView } from 'react-google-maps';
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
import { ensureListing } from '../../util/data';
import css from './SearchMapGroupLabel.css';
// FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
// Override draw function to catch errors with map panes being undefined to prevent console errors
// https://github.com/tomchentw/react-google-maps/issues/482
class CustomOverlayView extends OverlayView {
draw() {
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
// Add conditional to ensure panes and container exist before drawing
if (mapPanes && this.containerElement) {
super.draw();
}
}
}
// Center label so that caret is pointing to correct pixel.
// (vertical positioning: height + arrow) */
const getPixelPositionOffset = (width, height) => {
return { x: -1 * (width / 2), y: -1 * (height + 3) };
};
class SearchMapGroupLabel extends Component {
shouldComponentUpdate(nextProps) {
const hasSameAmountOfListings = nextProps.listings.length === this.props.listings.length;
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
const hasSameRefreshToken =
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
return !(hasSameAmountOfListings && hasSameActiveStatus);
return !(hasSameAmountOfListings && hasSameActiveStatus && hasSameRefreshToken);
}
render() {
const { className, rootClassName, listings, onListingClicked, isActive } = this.props;
const firstListing = ensureListing(listings[0]);
const geolocation = firstListing.attributes.geolocation;
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
const classes = classNames(rootClassName || css.root, className);
const countLabelClasses = classNames(css.details, { [css.detailsActive]: isActive });
const caretClasses = classNames(css.caret, { [css.caretActive]: isActive });
return (
<CustomOverlayView
position={latLngLiteral}
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
getPixelPositionOffset={getPixelPositionOffset}
>
<button className={classes} onClick={() => onListingClicked(listings)}>
<div className={css.caretShadow} />
<div className={countLabelClasses}>{listings.length}</div>
<div className={caretClasses} />
</button>
</CustomOverlayView>
<button className={classes} onClick={() => onListingClicked(listings)}>
<div className={css.caretShadow} />
<div className={countLabelClasses}>{listings.length}</div>
<div className={caretClasses} />
</button>
);
}
}

View file

@ -1,7 +1,5 @@
import React, { Component } from 'react';
import { arrayOf, bool, func, string } from 'prop-types';
import { OverlayView } from 'react-google-maps';
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
import { compose } from 'redux';
import { injectIntl, intlShape } from 'react-intl';
import classNames from 'classnames';
@ -13,26 +11,6 @@ import { ResponsiveImage } from '../../components';
import css from './SearchMapInfoCard.css';
// FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
// Override draw function to catch errors with map panes being undefined to prevent console errors
// https://github.com/tomchentw/react-google-maps/issues/482
class CustomOverlayView extends OverlayView {
draw() {
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
// Add conditional to ensure panes and container exist before drawing
if (mapPanes && this.containerElement) {
super.draw();
}
}
}
// Center label so that caret is pointing to correct pixel.
// (vertical positioning: height + arrow) */
const getPixelPositionOffset = (width, height) => {
return { x: -1 * (width / 2), y: -1 * (height + 3) };
};
// ListingCard is the listing info without overlayview or carousel controls
const ListingCard = props => {
const { className, clickHandler, intl, isInCarousel, listing, urlToListing } = props;
@ -116,10 +94,6 @@ class SearchMapInfoCard extends Component {
onListingInfoCardClicked,
} = this.props;
const currentListing = ensureListing(listings[this.state.currentListingIndex]);
const geolocation = currentListing.attributes.geolocation;
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
const hasCarousel = listings.length > 1;
const pagination = hasCarousel ? (
<div className={classNames(css.paginationInfo, css.borderRadiusInheritBottom)}>
@ -155,25 +129,18 @@ class SearchMapInfoCard extends Component {
const caretClass = classNames(css.caret, { [css.caretWithCarousel]: hasCarousel });
return (
<CustomOverlayView
position={latLngLiteral}
mapPaneName={OverlayView.FLOAT_PANE}
getPixelPositionOffset={getPixelPositionOffset}
styles={{ zIndex: 1 }}
>
<div className={classes}>
<div className={css.caretShadow} />
<ListingCard
clickHandler={onListingInfoCardClicked}
urlToListing={createURLToListing(currentListing)}
listing={currentListing}
intl={intl}
isInCarousel={hasCarousel}
/>
{pagination}
<div className={caretClass} />
</div>
</CustomOverlayView>
<div className={classes}>
<div className={css.caretShadow} />
<ListingCard
clickHandler={onListingInfoCardClicked}
urlToListing={createURLToListing(currentListing)}
listing={currentListing}
intl={intl}
isInCarousel={hasCarousel}
/>
{pagination}
<div className={caretClass} />
</div>
);
}
}

View file

@ -1,7 +1,5 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { OverlayView } from 'react-google-maps';
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
import { injectIntl, intlShape } from 'react-intl';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
@ -11,26 +9,6 @@ import config from '../../config';
import css from './SearchMapPriceLabel.css';
// FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
// Override draw function to catch errors with map panes being undefined to prevent console errors
// https://github.com/tomchentw/react-google-maps/issues/482
class CustomOverlayView extends OverlayView {
draw() {
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
// Add conditional to ensure panes and container exist before drawing
if (mapPanes && this.containerElement) {
super.draw();
}
}
}
// Center label so that caret is pointing to correct pixel.
// (vertical positioning: height + arrow) */
const getPixelPositionOffset = (width, height) => {
return { x: -1 * (width / 2), y: -1 * (height + 3) };
};
class SearchMapPriceLabel extends Component {
shouldComponentUpdate(nextProps) {
const currentListing = ensureListing(this.props.listing);
@ -38,37 +16,31 @@ class SearchMapPriceLabel extends Component {
const isSameListing = currentListing.id.uuid === nextListing.id.uuid;
const hasSamePrice = currentListing.attributes.price === nextListing.attributes.price;
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
const hasSameRefreshToken =
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
return !(isSameListing && hasSamePrice && hasSameActiveStatus);
return !(isSameListing && hasSamePrice && hasSameActiveStatus && hasSameRefreshToken);
}
render() {
const { className, rootClassName, intl, listing, onListingClicked, isActive } = this.props;
const currentListing = ensureListing(listing);
const { geolocation, price } = currentListing.attributes;
const { price } = currentListing.attributes;
// Create formatted price if currency is known or alternatively show just the unknown currency.
const formattedPrice =
price && price.currency === config.currency ? formatMoney(intl, price) : price.currency;
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
const classes = classNames(rootClassName || css.root, className);
const priceLabelClasses = classNames(css.priceLabel, { [css.priceLabelActive]: isActive });
const caretClasses = classNames(css.caret, { [css.caretActive]: isActive });
return (
<CustomOverlayView
position={latLngLiteral}
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
getPixelPositionOffset={getPixelPositionOffset}
>
<button className={classes} onClick={() => onListingClicked(currentListing)}>
<div className={css.caretShadow} />
<div className={priceLabelClasses}>{formattedPrice}</div>
<div className={caretClasses} />
</button>
</CustomOverlayView>
<button className={classes} onClick={() => onListingClicked(currentListing)}>
<div className={css.caretShadow} />
<div className={priceLabelClasses}>{formattedPrice}</div>
<div className={caretClasses} />
</button>
);
}
}

View file

@ -5,17 +5,10 @@ import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import unionWith from 'lodash/unionWith';
import classNames from 'classnames';
import config from '../../config';
import routeConfiguration from '../../routeConfiguration';
import {
googleLatLngToSDKLatLng,
googleBoundsToSDKBounds,
sdkBoundsToFixedCoordinates,
hasSameSDKBounds,
} from '../../util/googleMaps';
import { createResourceLocatorString, pathByRouteName } from '../../util/routes';
import { parse, stringify } from '../../util/urlHelpers';
import { propTypes } from '../../util/types';
@ -40,7 +33,6 @@ import css from './SearchPage.css';
const RESULT_PAGE_SIZE = 24;
const MODAL_BREAKPOINT = 768; // Search is in modal on mobile layout
const SEARCH_WITH_MAP_DEBOUNCE = 300; // Little bit of debounce before search is initiated.
const BOUNDS_FIXED_PRECISION = 8;
export class SearchPageComponent extends Component {
constructor(props) {
@ -51,17 +43,10 @@ export class SearchPageComponent extends Component {
isMobileModalOpen: false,
};
// Initiating map creates 'bounds_changes' event
// we listen to that event to make new searches
// So, if the search comes from location search input (this.viewportBounds == null),
// we need to by pass extra searches created by Google Map's 'indle' event.
// This is done by keeping track of map's viewport bounds (which differ from location bounds)
this.viewportBounds = null;
this.modalOpenedBoundsChange = false;
this.searchMapListingsInProgress = false;
this.filters = this.filters.bind(this);
this.onIdle = debounce(this.onIdle.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
this.onMapMoveEnd = debounce(this.onMapMoveEnd.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
this.onOpenMobileModal = this.onOpenMobileModal.bind(this);
this.onCloseMobileModal = this.onCloseMobileModal.bind(this);
}
@ -81,56 +66,35 @@ export class SearchPageComponent extends Component {
};
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.location, nextProps.location)) {
// If no mapSearch url parameter is given, this is original location search
const { mapSearch } = parse(nextProps.location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
if (!mapSearch) {
this.viewportBounds = null;
}
}
}
// We are using Google Maps idle event instead of bounds_changed, since it will not be fired
// too often (in the middle of map's pan or zoom activity)
onIdle(googleMap) {
const { history, location } = this.props;
// parse query parameters, including a custom attribute named category
const { address, bounds, mapSearch, ...rest } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const viewportGMapBounds = googleMap.getBounds();
const viewportBounds = sdkBoundsToFixedCoordinates(
googleBoundsToSDKBounds(viewportGMapBounds),
BOUNDS_FIXED_PRECISION
);
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
const viewportBoundsChanged =
this.viewportBounds && !hasSameSDKBounds(this.viewportBounds, viewportBounds);
// Callback to determine if new search is needed
// when map is moved by user or viewport has changed
onMapMoveEnd(viewportBoundsChanged, data) {
const { viewportBounds, viewportCenter } = data;
const routes = routeConfiguration();
const searchPagePath = pathByRouteName('SearchPage', routes);
const currentPath =
typeof window !== 'undefined' && window.location && window.location.pathname;
// When using the ReusableMapContainer onIdle can fire from other pages than SearchPage too
// When using the ReusableMapContainer onMapMoveEnd can fire from other pages than SearchPage too
const isSearchPage = currentPath === searchPagePath;
// If mapSearch url param is given (and we have not just opened mobile map modal)
// If mapSearch url param is given
// or original location search is rendered once,
// we start to react to 'bounds_changed' event by generating new searches
if (viewportBoundsChanged && !this.modalOpenedBoundsChange && isSearchPage) {
const originMaybe = config.sortSearchByDistance
? { origin: googleLatLngToSDKLatLng(viewportGMapBounds.getCenter()) }
: {};
// we start to react to "mapmoveend" events by generating new searches
// (i.e. 'moveend' event in Mapbox and 'bounds_changed' in Google Maps)
if (viewportBoundsChanged && isSearchPage) {
const { history, location } = this.props;
// parse query parameters, including a custom attribute named category
const { address, bounds, mapSearch, ...rest } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
//const viewportMapCenter = SearchMap.getMapCenter(map);
const originMaybe = config.sortSearchByDistance ? { origin: viewportCenter } : {};
const searchParams = {
address,
...originMaybe,
@ -139,11 +103,7 @@ export class SearchPageComponent extends Component {
...validFilterParams(rest, this.filters()),
};
this.viewportBounds = viewportBounds;
history.push(createResourceLocatorString('SearchPage', routes, {}, searchParams));
} else {
this.viewportBounds = viewportBounds;
this.modalOpenedBoundsChange = false;
}
}
@ -200,7 +160,6 @@ export class SearchPageComponent extends Component {
const onMapIconClick = () => {
this.useLocationSearchBounds = true;
this.modalOpenedBoundsChange = true;
this.setState({ isSearchMapOpenOnMobile: true });
};
@ -263,12 +222,13 @@ export class SearchPageComponent extends Component {
activeListingId={activeListingId}
bounds={bounds}
center={origin}
isSearchMapOpenOnMobile={this.state.isSearchMapOpenOnMobile}
location={location}
listings={mapListings || []}
onIdle={this.onIdle}
onMapMoveEnd={this.onMapMoveEnd}
onCloseAsModal={() => {
onManageDisableScrolling('SearchPage.map', false);
}}
useLocationSearchBounds={!this.viewportBounds}
/>
) : null}
</div>

View file

@ -88,10 +88,15 @@ exports[`SearchPageComponent matches snapshot 1`] = `
<div>
<withRouter(SearchMapComponent)
activeListingId={null}
isSearchMapOpenOnMobile={false}
listings={Array []}
location={
Object {
"search": "",
}
}
onCloseAsModal={[Function]}
onIdle={[Function]}
useLocationSearchBounds={true}
onMapMoveEnd={[Function]}
/>
</div>
</withViewport(ModalInMobileComponent)>

View file

@ -1,4 +1,4 @@
// NOTE: renderdeep doesn't work due to Google Maps API integration
// NOTE: renderdeep doesn't work due to map integration
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';

View file

@ -1,4 +1,4 @@
// NOTE: renderdeep doesn't work due to Google Maps API integration
// NOTE: renderdeep doesn't work due to map integration
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';

View file

@ -118,7 +118,8 @@
/* ================ LocationAutocompleteInput bottom attribution padding ================ */
--locationAutocompleteBottomPadding: 72px;
/* Google Maps needs 72px bottom padding to accommodate logo, Mapbox doesn't have one */
--locationAutocompleteBottomPadding: 8px;
/* ================ Clearfix solution ================ */

View file

@ -90,33 +90,7 @@ export const getPlacePredictions = (search, sessionToken) =>
});
/**
* Convert Google formatted LatLng object to Sharetribe SDK's LatLng coordinate format
*
* @param {LatLng} googleLatLng - Google Maps LatLng
*
* @return {SDKLatLng} - Converted latLng coordinate
*/
export const googleLatLngToSDKLatLng = googleLatLng => {
return new SDKLatLng(googleLatLng.lat(), googleLatLng.lng());
};
/**
* Convert Google formatted bounds object to Sharetribe SDK's bounds format
*
* @param {LatLngBounds} googleBounds - Google Maps LatLngBounds
*
* @return {SDKLatLngBounds} - Converted bounds
*/
export const googleBoundsToSDKBounds = googleBounds => {
if (!googleBounds) {
return null;
}
const ne = googleBounds.getNorthEast();
const sw = googleBounds.getSouthWest();
return new SDKLatLngBounds(new SDKLatLng(ne.lat(), ne.lng()), new SDKLatLng(sw.lat(), sw.lng()));
};
/**
* Deprecation: use function from src/util/maps.js
* Cut some precision from bounds coordinates to tackle subtle map movements
* when map is moved manually
*
@ -134,6 +108,7 @@ export const sdkBoundsToFixedCoordinates = (sdkBounds, fixedPrecision) => {
};
/**
* Deprecation: use function from src/util/maps.js
* Check if given bounds object have the same coordinates
*
* @param {LatLngBounds} sdkBounds1 - bounds #1 to be compared

View file

@ -3,7 +3,7 @@ import seedrandom from 'seedrandom';
import { types as sdkTypes } from './sdkLoader';
import config from '../config';
const { LatLng } = sdkTypes;
const { LatLng, LatLngBounds } = sdkTypes;
const EARTH_RADIUS = 6371000; /* meters */
const DEG_TO_RAD = Math.PI / 180.0;
@ -155,3 +155,40 @@ export const circlePolyline = (latlng, radius) => {
return points;
};
/**
* Cut some precision from bounds coordinates to tackle subtle map movements
* when map is moved manually
*
* @param {LatLngBounds} sdkBounds - bounds to be changed to fixed precision
* @param {Number} fixedPrecision - integer to be used on tofixed() change.
*
* @return {LatLngBounds} - bounds cut to given fixed precision
*/
export const sdkBoundsToFixedCoordinates = (sdkBounds, fixedPrecision) => {
const fixed = n => Number.parseFloat(n.toFixed(fixedPrecision));
const ne = new LatLng(fixed(sdkBounds.ne.lat), fixed(sdkBounds.ne.lng));
const sw = new LatLng(fixed(sdkBounds.sw.lat), fixed(sdkBounds.sw.lng));
return new LatLngBounds(ne, sw);
};
/**
* Check if given bounds object have the same coordinates
*
* @param {LatLngBounds} sdkBounds1 - bounds #1 to be compared
* @param {LatLngBounds} sdkBounds2 - bounds #2 to be compared
*
* @return {boolean} - true if bounds are the same
*/
export const hasSameSDKBounds = (sdkBounds1, sdkBounds2) => {
if (!(sdkBounds1 instanceof LatLngBounds) || !(sdkBounds2 instanceof LatLngBounds)) {
return false;
}
return (
sdkBounds1.ne.lat === sdkBounds2.ne.lat &&
sdkBounds1.ne.lng === sdkBounds2.ne.lng &&
sdkBounds1.sw.lat === sdkBounds2.sw.lat &&
sdkBounds1.sw.lng === sdkBounds2.sw.lng
);
};

View file

@ -5032,6 +5032,10 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
mapbox-gl-multitouch@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/mapbox-gl-multitouch/-/mapbox-gl-multitouch-1.0.3.tgz#db8bbe86a15d8398e3315d97305c9edde3f0f0d7"
marker-clusterer-plus@^2.1.4:
version "2.1.4"
resolved "https://registry.yarnpkg.com/marker-clusterer-plus/-/marker-clusterer-plus-2.1.4.tgz#f8eff74d599dab3b7d0e3fed5264ea0e704f5d67"