Merge pull request #896 from sharetribe/mapbox-map

Mapbox Map component
This commit is contained in:
Kimmo Puputti 2018-08-15 12:41:53 +03:00 committed by GitHub
commit 5e9fb0272b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 359 additions and 64 deletions

View file

@ -12,6 +12,8 @@ way to update this template, but currently, we follow a pattern:
---
## Upcoming version 2018-08-XX
* [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)

View file

@ -4,10 +4,10 @@ import { withGoogleMap, GoogleMap, Marker, Circle } from 'react-google-maps';
import config from '../../config';
/**
* DynamicMap uses withGoogleMap HOC.
* DynamicGoogleMap uses withGoogleMap HOC.
* It handles some of the google map initialization states.
*/
const DynamicMap = withGoogleMap(props => {
const DynamicGoogleMap = withGoogleMap(props => {
const { center, zoom, address, coordinatesConfig } = props;
const { markerURI, anchorX, anchorY, width, height } = coordinatesConfig.customMarker || {};
@ -34,6 +34,11 @@ const DynamicMap = withGoogleMap(props => {
const circle = <Circle {...circleProps} />;
const controlPosition =
typeof window !== 'undefined' && typeof window.google !== 'undefined'
? window.google.maps.ControlPosition.LEFT_TOP
: 5;
return (
<GoogleMap
defaultZoom={zoom}
@ -45,7 +50,13 @@ const DynamicMap = withGoogleMap(props => {
// Disable zooming by scrolling
scrollwheel: false,
// Fullscreen control toggle
fullscreenControl: true,
fullscreenControl: false,
// Street View control
streetViewControl: false,
// Zoom control position
zoomControlOptions: {
position: controlPosition,
},
}}
>
{coordinatesConfig.fuzzy ? circle : marker}
@ -53,14 +64,14 @@ const DynamicMap = withGoogleMap(props => {
);
});
DynamicMap.defaultProps = {
DynamicGoogleMap.defaultProps = {
address: '',
center: null,
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
};
DynamicMap.propTypes = {
DynamicGoogleMap.propTypes = {
address: string,
center: shape({
lat: number.isRequired,
@ -70,4 +81,4 @@ DynamicMap.propTypes = {
coordinatesConfig: object,
};
export default DynamicMap;
export default DynamicGoogleMap;

View file

@ -0,0 +1,167 @@
import React, { Component } from 'react';
import { string, shape, number, object } from 'prop-types';
import uniqueId from 'lodash/uniqueId';
import { circlePolyline } from '../../util/maps';
import config from '../../config';
const mapMarker = coordinatesConfig => {
const { customMarker } = coordinatesConfig;
if (customMarker) {
const element = document.createElement('div');
element.style.backgroundImage = `url(${customMarker.markerURI})`;
element.style.width = `${customMarker.width}px`;
element.style.height = `${customMarker.height}px`;
return new window.mapboxgl.Marker({ element });
} else {
return new window.mapboxgl.Marker();
}
};
const circleLayer = (center, coordinatesConfig, layerId) => {
const { fillColor, fillOpacity } = coordinatesConfig.circleOptions;
const path = circlePolyline(center, coordinatesConfig.coordinateOffset).map(([lat, lng]) => [
lng,
lat,
]);
return {
id: layerId,
type: 'fill',
source: {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [path],
},
},
},
paint: {
'fill-color': fillColor,
'fill-opacity': fillOpacity,
},
};
};
const generateFuzzyLayerId = () => {
return uniqueId('fuzzy_layer_');
};
class DynamicMapboxMap extends Component {
constructor(props) {
super(props);
this.mapContainer = null;
this.map = null;
this.centerMarker = null;
this.fuzzyLayerId = generateFuzzyLayerId();
this.updateFuzzyCirclelayer = this.updateFuzzyCirclelayer.bind(this);
}
componentDidMount() {
const { center, zoom, coordinatesConfig } = this.props;
const position = [center.lng, center.lat];
this.map = new window.mapboxgl.Map({
container: this.mapContainer,
style: 'mapbox://styles/mapbox/streets-v10',
center: position,
zoom,
scrollZoom: false,
});
this.map.addControl(new window.mapboxgl.NavigationControl(), 'top-left');
if (coordinatesConfig.fuzzy) {
this.map.on('load', () => {
this.map.addLayer(circleLayer(center, coordinatesConfig, this.fuzzyLayerId));
});
} else {
this.centerMarker = mapMarker(coordinatesConfig);
this.centerMarker.setLngLat(position).addTo(this.map);
}
}
componentWillUnmount() {
if (this.map) {
this.centerMarker = null;
this.map.remove();
this.map = null;
}
}
componentDidUpdate(prevProps) {
if (!this.map) {
return;
}
const { center, zoom, coordinatesConfig } = this.props;
const { lat, lng } = center;
const position = [lng, lat];
// zoom change
if (zoom !== prevProps.zoom) {
this.map.setZoom(this.props.zoom);
}
const centerChanged = lat !== prevProps.center.lat || lng !== prevProps.center.lng;
// center marker change
if (this.centerMarker && centerChanged) {
this.centerMarker.setLngLat(position);
this.map.setCenter(position);
}
// fuzzy circle change
if (coordinatesConfig.fuzzy && centerChanged) {
if (this.map.loaded()) {
this.updateFuzzyCirclelayer();
} else {
this.map.on('load', this.updateFuzzyCirclelayer);
}
}
// NOTE: coordinatesConfig changes are not handled
}
updateFuzzyCirclelayer() {
if (!this.map) {
// map already removed
return;
}
const { center, coordinatesConfig } = this.props;
const { lat, lng } = center;
const position = [lng, lat];
this.map.removeLayer(this.fuzzyLayerId);
// We have to use a different layer id to avoid Mapbox errors
this.fuzzyLayerId = generateFuzzyLayerId();
this.map.addLayer(circleLayer(center, coordinatesConfig, this.fuzzyLayerId));
this.map.setCenter(position);
}
render() {
const { containerClassName, mapClassName } = this.props;
return (
<div className={containerClassName}>
<div className={mapClassName} ref={el => (this.mapContainer = el)} />
</div>
);
}
}
DynamicMapboxMap.defaultProps = {
address: '',
center: null,
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
};
DynamicMapboxMap.propTypes = {
address: string, // not used
center: shape({
lat: number.isRequired,
lng: number.isRequired,
}).isRequired,
zoom: number,
coordinatesConfig: object,
};
export default DynamicMapboxMap;

View file

@ -0,0 +1,6 @@
export { default as DynamicMap } from './DynamicGoogleMap';
export { default as StaticMap } from './StaticGoogleMap';
export const isMapsLibLoaded = () => {
return typeof window !== 'undefined' && window.google && window.google.maps;
};

View file

@ -3,9 +3,9 @@ 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 DynamicMap from './DynamicMap';
import StaticMap from './StaticMap';
import css from './Map.css';
export class Map extends Component {
@ -34,24 +34,23 @@ export class Map extends Component {
}
const location = coordinatesConfig.fuzzy ? obfuscatedCenter : center;
const centerLocationForGoogleMap = { lat: location.lat, lng: location.lng };
const isMapsLibLoaded = typeof window !== 'undefined' && window.google && window.google.maps;
return !isMapsLibLoaded ? (
return !isMapsLibLoaded() ? (
<div className={classes} />
) : useStaticMap ? (
<StaticMap
center={centerLocationForGoogleMap}
center={location}
zoom={zoom}
address={address}
coordinatesConfig={coordinatesConfig}
/>
) : (
<DynamicMap
containerElement={<div className={classes} onClick={this.onMapClicked} />}
containerElement={<div className={classes} />}
mapElement={<div className={mapClasses} />}
center={centerLocationForGoogleMap}
containerClassName={classes}
mapClassName={mapClasses}
center={location}
zoom={zoom}
address={address}
coordinatesConfig={coordinatesConfig}

View file

@ -0,0 +1,6 @@
export { default as DynamicMap } from './DynamicMapboxMap';
export { default as StaticMap } from './StaticMapboxMap';
export const isMapsLibLoaded = () => {
return typeof window !== 'undefined' && window.mapboxgl;
};

View file

@ -5,46 +5,13 @@ import isEqual from 'lodash/isEqual';
import polyline from '@mapbox/polyline';
import { encodeLatLng, stringify } from '../../util/urlHelpers';
import { lazyLoadWithDimensions } from '../../util/contextHelpers';
import { circlePolyline } from '../../util/maps';
import config from '../../config';
const DEFAULT_COLOR = 'FF0000';
const DEFAULT_STROKE_OPACITY = 0.3;
const DEFAULT_FILL_OPACITY = 0.2;
// Return polyline encoded list of points forming a circle
// This algorithm is based on
// https://stackoverflow.com/questions/7316963/drawing-a-circle-google-static-maps
const getEncodedGMapCirclePoints = (lat, lng, rad, detail = 8) => {
const R = 6371;
const pi = Math.PI;
const _lat = lat * pi / 180;
const _lng = lng * pi / 180;
const d = rad / 1000 / R;
let points = [];
for (let i = 0; i <= 360; i += detail) {
const brng = i * pi / 180;
let pLat = Math.asin(
Math.sin(_lat) * Math.cos(d) + Math.cos(_lat) * Math.sin(d) * Math.cos(brng)
);
const pLng =
(_lng +
Math.atan2(
Math.sin(brng) * Math.sin(d) * Math.cos(_lat),
Math.cos(d) - Math.sin(_lat) * Math.sin(pLat)
)) *
180 /
pi;
pLat = pLat * 180 / pi;
points.push([pLat.toFixed(6), pLng.toFixed(6)]);
}
return polyline.encode(points);
};
// Extract color from string. Given value should be either with '#' (e.g. #FFFFFF') or without it.
const formatColorFromString = color => {
if (typeof color === 'string' && /^#[0-9A-F]{6}$/i.test(color)) {
@ -80,7 +47,6 @@ const drawFuzzyCircle = (coordinatesConfig, center) => {
}
const { fillColor, fillOpacity, strokeColor, strokeWeight } = coordinatesConfig.circleOptions;
const { lat, lng } = center;
const circleRadius = coordinatesConfig.coordinateOffset || 500;
const circleStrokeWeight = strokeWeight || 1;
@ -89,8 +55,8 @@ const drawFuzzyCircle = (coordinatesConfig, center) => {
const circleFill = formatColorFromString(fillColor);
const circleFillOpacity = convertOpacity(fillOpacity || DEFAULT_FILL_OPACITY);
//Encoded polyline string
const encodedPolyline = getEncodedGMapCirclePoints(lat, lng, circleRadius, 8);
// Encoded polyline string
const encodedPolyline = polyline.encode(circlePolyline(center, circleRadius));
const polylineGraphicTokens = [
`color:0x${circleStrokeColor}${circleStrokeOpacity}`,
@ -108,7 +74,7 @@ const customMarker = (options, lat, lng) => {
return [`anchor:${anchorX},${anchorY}`, `icon:${markerURI}`, `${lat},${lng}`].join('|');
};
class StaticMap extends Component {
class StaticGoogleMap extends Component {
shouldComponentUpdate(nextProps, prevState) {
// Do not draw the map unless center, zoom or dimensions change
// We want to prevent unnecessary calls to Google Maps APIs due
@ -147,7 +113,7 @@ class StaticMap extends Component {
}
}
StaticMap.defaultProps = {
StaticGoogleMap.defaultProps = {
className: null,
rootClassName: null,
address: '',
@ -156,7 +122,7 @@ StaticMap.defaultProps = {
coordinatesConfig: config.coordinates,
};
StaticMap.propTypes = {
StaticGoogleMap.propTypes = {
className: string,
rootClassName: string,
address: string,
@ -174,4 +140,4 @@ StaticMap.propTypes = {
}).isRequired,
};
export default lazyLoadWithDimensions(StaticMap, { maxWidth: '640px' });
export default lazyLoadWithDimensions(StaticGoogleMap, { maxWidth: '640px' });

View file

@ -0,0 +1,89 @@
import React from 'react';
import { string, shape, number, object } from 'prop-types';
import polyline from '@mapbox/polyline';
import { lazyLoadWithDimensions } from '../../util/contextHelpers';
import { circlePolyline } from '../../util/maps';
import config from '../../config';
const formatColor = color => {
return color.replace(/^#/, '');
};
const fuzzyCircleOverlay = (center, coordinatesConfig) => {
const {
strokeWeight,
strokeColor,
strokeOpacity,
fillColor,
fillOpacity,
} = coordinatesConfig.circleOptions;
const path = circlePolyline(center, coordinatesConfig.coordinateOffset);
const styles = `-${strokeWeight}+${formatColor(strokeColor)}-${strokeOpacity}+${formatColor(
fillColor
)}-${fillOpacity}`;
return `path${styles}(${encodeURIComponent(polyline.encode(path))})`;
};
const customMarkerOverlay = (center, coordinatesConfig) => {
const { markerURI } = coordinatesConfig.customMarker;
return `url-${encodeURIComponent(markerURI)}(${center.lng},${center.lat})`;
};
const markerOverlay = center => {
return `pin-s(${center.lng},${center.lat})`;
};
const mapOverlay = (center, coordinatesConfig) => {
if (coordinatesConfig.fuzzy) {
return fuzzyCircleOverlay(center, coordinatesConfig);
}
if (coordinatesConfig.customMarker) {
return customMarkerOverlay(center, coordinatesConfig);
}
return markerOverlay(center);
};
const StaticMapboxMap = props => {
const { address, center, zoom, coordinatesConfig, dimensions } = props;
const { width, height } = dimensions;
const libLoaded = typeof window !== 'undefined' && window.mapboxgl;
if (!libLoaded) {
return null;
}
const overlay = mapOverlay(center, coordinatesConfig);
const src =
'https://api.mapbox.com/styles/v1/mapbox/streets-v10/static' +
(overlay ? `/${overlay}` : '') +
`/${center.lng},${center.lat},${zoom}` +
`/${width}x${height}` +
`?access_token=${window.mapboxgl.accessToken}`;
return <img src={src} alt={address} />;
};
StaticMapboxMap.defaultProps = {
address: '',
center: null,
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
};
StaticMapboxMap.propTypes = {
address: string,
center: shape({
lat: number.isRequired,
lng: number.isRequired,
}).isRequired,
zoom: number,
coordinatesConfig: object,
// from withDimensions
dimensions: shape({
width: number.isRequired,
height: number.isRequired,
}).isRequired,
};
export default lazyLoadWithDimensions(StaticMapboxMap, { maxWidth: '640px' });

View file

@ -28,20 +28,25 @@ class SectionMapMaybe extends Component {
const mapProps = config.coordinates.fuzzy
? { obfuscatedCenter: obfuscatedCoordinates(geolocation, listingId ? listingId.uuid : null) }
: { address, center: geolocation };
const map = <Map {...mapProps} useStaticMap={this.state.isStatic} />;
return (
<div className={classes}>
<h2 className={css.locationTitle}>
<FormattedMessage id="ListingPage.locationTitle" />
</h2>
<button
className={css.map}
onClick={() => {
this.setState({ isStatic: false });
}}
>
<Map {...mapProps} useStaticMap={this.state.isStatic} />
</button>
{this.state.isStatic ? (
<button
className={css.map}
onClick={() => {
this.setState({ isStatic: false });
}}
>
{map}
</button>
) : (
<div className={css.map}>{map}</div>
)}
</div>
);
}

View file

@ -86,3 +86,47 @@ export const userLocation = () =>
navigator.geolocation.getCurrentPosition(onSuccess, onError);
});
/**
* Calculate a circular polyline around the given point
*
* See: https://stackoverflow.com/questions/7316963/drawing-a-circle-google-static-maps
*
* @param {LatLng} latlng - center of the circle
* @param {Number} radius - radius of the circle
*
* @return {Array<Array<Number>>} array of `[lat, lng]` coordinate
* pairs forming the circle
*/
export const circlePolyline = (latlng, radius) => {
const { lat, lng } = latlng;
const detail = 8;
const R = 6371;
const pi = Math.PI;
const _lat = lat * pi / 180;
const _lng = lng * pi / 180;
const d = radius / 1000 / R;
let points = [];
for (let i = 0; i <= 360; i += detail) {
const brng = i * pi / 180;
let pLat = Math.asin(
Math.sin(_lat) * Math.cos(d) + Math.cos(_lat) * Math.sin(d) * Math.cos(brng)
);
const pLng =
(_lng +
Math.atan2(
Math.sin(brng) * Math.sin(d) * Math.cos(_lat),
Math.cos(d) - Math.sin(_lat) * Math.sin(pLat)
)) *
180 /
pi;
pLat = pLat * 180 / pi;
points.push([pLat, pLng]);
}
return points;
};