Merge pull request #869 from sharetribe/listingpage-static-map

Listingpage static map
This commit is contained in:
Vesa Luusua 2018-07-19 14:47:03 +03:00 committed by GitHub
commit 8270dcbcb9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 404 additions and 56 deletions

View file

@ -13,6 +13,8 @@ way to update this template, but currently, we follow a pattern:
---
## Upcoming version
* [change] Use Google's static map on ListingPage.
[#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)

View file

@ -4,6 +4,7 @@
"private": true,
"license": "Apache-2.0",
"dependencies": {
"@mapbox/polyline": "^1.0.0",
"array-includes": "^3.0.3",
"array.prototype.find": "^2.0.4",
"autosize": "^4.0.0",

View file

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,69 @@
import React from 'react';
import { number, object, shape, string } from 'prop-types';
import { withGoogleMap, GoogleMap, Marker, Circle } from 'react-google-maps';
import config from '../../config';
/**
* DynamicMap uses withGoogleMap HOC.
* It handles some of the google map initialization states.
*/
const DynamicMap = withGoogleMap(props => {
const { center, zoom, address, coordinatesConfig } = props;
const { markerURI, anchorX, anchorY, width, height } = coordinatesConfig.customMarker;
const markerIcon = {
url: markerURI,
// The origin for this image is (0, 0).
origin: new window.google.maps.Point(0, 0),
size: new window.google.maps.Size(width, height),
anchor: new window.google.maps.Point(anchorX, anchorY),
};
const marker = <Marker position={center} icon={markerIcon} title={address} />;
const circleProps = {
options: coordinatesConfig.circleOptions,
radius: coordinatesConfig.coordinateOffset,
center,
};
const circle = <Circle {...circleProps} />;
return (
<GoogleMap
defaultZoom={zoom}
defaultCenter={center}
center={center}
options={{
// Disable map type (ie. Satellite etc.)
mapTypeControl: false,
// Disable zooming by scrolling
scrollwheel: false,
// Fullscreen control toggle
fullscreenControl: true,
}}
>
{coordinatesConfig.fuzzy ? circle : marker}
</GoogleMap>
);
});
DynamicMap.defaultProps = {
address: '',
center: null,
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
};
DynamicMap.propTypes = {
address: string,
center: shape({
lat: number.isRequired,
lng: number.isRequired,
}).isRequired,
zoom: number,
coordinatesConfig: object,
};
export default DynamicMap;

View file

@ -1,59 +1,13 @@
import React, { Component } from 'react';
import { string, number, object } from 'prop-types';
import { withGoogleMap, GoogleMap, Marker, Circle } from 'react-google-maps';
import { bool, number, object, string } from 'prop-types';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
import config from '../../config';
import CustomMarker from './images/marker-32x32.png';
import DynamicMap from './DynamicMap';
import StaticMap from './StaticMap';
import css from './Map.css';
/**
* MapWithGoogleMap uses withGoogleMap HOC.
* It handles some of the google map initialization states.
*/
const MapWithGoogleMap = withGoogleMap(props => {
const { center, zoom, address, coordinatesConfig } = props;
const markerIcon = {
url: CustomMarker,
// This marker is 32 pixels wide by 32 pixels high.
size: new window.google.maps.Size(32, 32),
// The origin for this image is (0, 0).
origin: new window.google.maps.Point(0, 0),
// The anchor for the marker is in the bottom center.
anchor: new window.google.maps.Point(16, 32),
};
const marker = <Marker position={center} icon={markerIcon} title={address} />;
const circleProps = {
options: coordinatesConfig.circleOptions,
radius: coordinatesConfig.coordinateOffset,
center,
};
const circle = <Circle {...circleProps} />;
return (
<GoogleMap
defaultZoom={zoom}
defaultCenter={center}
center={center}
options={{
// Disable map type (ie. Satellite etc.)
mapTypeControl: false,
// Disable zooming by scrolling
scrollwheel: false,
// Fullscreen control toggle
fullscreenControl: true,
}}
>
{coordinatesConfig.fuzzy ? circle : marker}
</GoogleMap>
);
});
export class Map extends Component {
render() {
const {
@ -65,6 +19,7 @@ export class Map extends Component {
obfuscatedCenter,
zoom,
coordinatesConfig,
useStaticMap,
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const mapClasses = mapRootClassName || css.mapRoot;
@ -83,8 +38,17 @@ export class Map extends Component {
const isMapsLibLoaded = typeof window !== 'undefined' && window.google && window.google.maps;
return isMapsLibLoaded ? (
<MapWithGoogleMap
return !isMapsLibLoaded ? (
<div className={classes} />
) : useStaticMap ? (
<StaticMap
center={centerLocationForGoogleMap}
zoom={zoom}
address={address}
coordinatesConfig={coordinatesConfig}
/>
) : (
<DynamicMap
containerElement={<div className={classes} onClick={this.onMapClicked} />}
mapElement={<div className={mapClasses} />}
center={centerLocationForGoogleMap}
@ -92,8 +56,6 @@ export class Map extends Component {
address={address}
coordinatesConfig={coordinatesConfig}
/>
) : (
<div className={classes} />
);
}
}
@ -105,6 +67,7 @@ Map.defaultProps = {
address: '',
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
useStaticMap: false,
};
Map.propTypes = {
@ -116,6 +79,7 @@ Map.propTypes = {
obfuscatedCenter: propTypes.latlng,
zoom: number,
coordinatesConfig: object,
useStaticMap: bool,
};
export default Map;

View file

@ -0,0 +1,177 @@
import React, { Component } from 'react';
import { number, object, shape, string } from 'prop-types';
import pick from 'lodash/pick';
import isEqual from 'lodash/isEqual';
import polyline from '@mapbox/polyline';
import { encodeLatLng, stringify } from '../../util/urlHelpers';
import { lazyLoadWithDimensions } from '../../util/contextHelpers';
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)) {
return color.substring(1).toUpperCase();
} else if (typeof color === 'string' && /^[0-9A-F]{6}$/i) {
return color.toUpperCase();
} else {
return DEFAULT_COLOR;
}
};
// Convert opacity from floating point value (0.0 -> 1.0) to a hexadecimal format
const convertOpacity = opacity => {
if (typeof opacity === 'number' && !isNaN(opacity) && opacity >= 0 && opacity <= 1) {
// 0.2 => 20
return Math.floor(opacity * 255)
.toString(16)
.toUpperCase();
}
};
// Draw a circle polyline for fuzzy location.
const drawFuzzyCircle = (coordinatesConfig, center) => {
if (
!(
coordinatesConfig &&
typeof coordinatesConfig === 'object' &&
center &&
typeof center === 'object'
)
) {
return '';
}
const { fillColor, fillOpacity, strokeColor, strokeWeight } = coordinatesConfig.circleOptions;
const { lat, lng } = center;
const circleRadius = coordinatesConfig.coordinateOffset || 500;
const circleStrokeWeight = strokeWeight || 1;
const circleStrokeColor = formatColorFromString(strokeColor);
const circleStrokeOpacity = convertOpacity(DEFAULT_STROKE_OPACITY);
const circleFill = formatColorFromString(fillColor);
const circleFillOpacity = convertOpacity(fillOpacity || DEFAULT_FILL_OPACITY);
//Encoded polyline string
const encodedPolyline = getEncodedGMapCirclePoints(lat, lng, circleRadius, 8);
const polylineGraphicTokens = [
`color:0x${circleStrokeColor}${circleStrokeOpacity}`,
`fillcolor:0x${circleFill}${circleFillOpacity}`,
`weight:${circleStrokeWeight}`,
`enc:${encodedPolyline}`,
];
return polylineGraphicTokens.join('|');
};
// Get custom marker data for static map URI
const customMarker = (options, lat, lng) => {
const { anchorX, anchorY, markerURI } = options;
return [`anchor:${anchorX},${anchorY}`, `icon:${markerURI}`, `${lat},${lng}`].join('|');
};
class StaticMap 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
const currentData = pick(this.props, ['center', 'zoom', 'dimensions']);
const nextData = pick(nextProps, ['center', 'zoom', 'dimensions']);
return !isEqual(currentData, nextData);
}
render() {
const { center, zoom, address, coordinatesConfig, dimensions } = this.props;
const { lat, lng } = center || {};
const { width, height } = dimensions;
// Extra graphics for the static map image
// 1. if fuzzy coordinates are used, return circle path
// 2. if customMarker is defined in config.js, use that
// 3. else return default marker
const targetMaybe = coordinatesConfig.fuzzy
? { path: drawFuzzyCircle(coordinatesConfig, center) }
: coordinatesConfig.customMarker
? { markers: customMarker(coordinatesConfig.customMarker, lat, lng) }
: { markers: `${lat},${lng}` };
const srcParams = stringify({
center: encodeLatLng(center),
zoom,
size: `${width}x${height}`,
maptype: 'roadmap',
key: config.googleMapsAPIKey,
...targetMaybe,
});
return (
<img src={`https://maps.googleapis.com/maps/api/staticmap?${srcParams}`} alt={address} />
);
}
}
StaticMap.defaultProps = {
className: null,
rootClassName: null,
address: '',
center: null,
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
};
StaticMap.propTypes = {
className: string,
rootClassName: string,
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(StaticMap, { maxWidth: '640px' });

View file

@ -252,6 +252,9 @@ const siteFacebookPage = 'https://www.facebook.com/Sharetribe/';
// You should create one to track social sharing in Facebook
const facebookAppId = null;
// Google Maps API key is needed for static map images.
const googleMapsAPIKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
const coordinates = {
// If true, obfuscate the coordinates of the listings that are shown
// on a map.
@ -280,9 +283,26 @@ const coordinates = {
fillColor: '#c0392b',
fillOpacity: 0.2,
strokeColor: '#c0392b',
strokeWeight: 0.5,
strokeOpacity: 0.5,
strokeWeight: 1,
clickable: false,
},
// An option to use custom marker on static maps. Uncomment to enable it.
// https://developers.google.com/maps/documentation/maps-static/dev-guide#Markers
//
// Note 1: fuzzy coordinate circle overwrites these custom marker settings)
// Note 2: markerURI needs to be a public URI accessible by Google Maps API servers.
// The easiest place is /public/static/icons/ folder, but then the marker image is not available
// while developing through localhost.
// customMarker: {
// markerURI: encodeURI(`${canonicalRootURL}/static/icons/map-marker-32x32.png`),
// anchorX: 16,
// anchorY: 32,
// width: 32,
// height: 32,
// },
};
// NOTE: only expose configuration that should be visible in the
@ -318,6 +338,7 @@ const config = {
facebookAppId,
sentryDsn,
usingSSL,
googleMapsAPIKey,
coordinates,
custom,
};

View file

@ -530,6 +530,9 @@
/* Dimensions: Map takes all available space from viewport (excludes action button and section title) */
height: calc(100vh - 193px);
width: 100%;
/* Static map: max-width is 640px */
max-width: 640px;
background-color: #eee;
@media (--viewportMedium) {

View file

@ -29,7 +29,7 @@ const SectionMapMaybe = props => {
<FormattedMessage id="ListingPage.locationTitle" />
</h2>
<div className={css.map}>
<Map {...mapProps} />
<Map {...mapProps} useStaticMap />
</div>
</div>
);

View file

@ -103,7 +103,8 @@ exports[`SearchPageComponent matches snapshot 1`] = `
"fillColor": "#c0392b",
"fillOpacity": 0.2,
"strokeColor": "#c0392b",
"strokeWeight": 0.5,
"strokeOpacity": 0.5,
"strokeWeight": 1,
},
"coordinateOffset": 500,
"fuzzy": false,

View file

@ -68,3 +68,109 @@ export const withViewport = Component => {
return WithViewportComponent;
};
/**
* A higher order component (HOC) that lazy loads the current element and provides
* dimensions to the wrapped component as a `dimensions` prop that has
* the shape `{ width: 600, height: 400}`.
*
* @param {React.Component} Component to be wrapped by this HOC
* @param {Object} options pass in options like maxWidth and maxHeight.
*
* @return {Object} HOC component which knows its dimensions
*/
export const lazyLoadWithDimensions = (Component, options) => {
// The resize event is flooded when the browser is resized. We'll
// use a small timeout to throttle changing the viewport since it
// will trigger rerendering.
const THROTTLE_WAIT_MS = 200;
// First render default wait after mounting (small wait for styled paint)
const RENDER_WAIT_MS = 100;
class LazyLoadWithDimensionsComponent extends ReactComponent {
constructor(props) {
super(props);
this.element = null;
this.defaultRenderTimeout = null;
this.state = { width: 0, height: 0 };
this.handleWindowResize = this.handleWindowResize.bind(this);
this.isElementInViewport = this.isElementInViewport.bind(this);
this.setDimensions = throttle(this.setDimensions.bind(this), THROTTLE_WAIT_MS);
}
componentDidMount() {
window.addEventListener('scroll', this.handleWindowResize);
window.addEventListener('resize', this.handleWindowResize);
window.addEventListener('orientationchange', this.handleWindowResize);
this.defaultRenderTimeout = window.setTimeout(() => {
if (this.isElementInViewport()) {
this.handleWindowResize();
}
}, RENDER_WAIT_MS);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.handleWindowResize);
window.removeEventListener('resize', this.handleWindowResize);
window.removeEventListener('orientationchange', this.handleWindowResize);
window.clearTimeout(this.defaultRenderTimeout);
}
handleWindowResize() {
this.setDimensions();
}
setDimensions() {
this.setState(prevState => {
const { clientWidth, clientHeight } = this.element || { clientWidth: 0, clientHeight: 0 };
return { width: clientWidth, height: clientHeight };
});
}
isElementInViewport() {
if (this.element) {
const rect = this.element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
return false;
}
render() {
const dimensions = this.state;
const { width, height } = dimensions;
const props = { ...this.props, dimensions };
// lazyLoadWithDimensions HOC needs to take all given space
// unless max dimensions are provided through options.
const { maxWidth, maxHeight } = options;
const maxWidthMaybe = maxWidth ? { maxWidth } : {};
const maxHeightMaybe = maxHeight ? { maxHeight } : {};
const style = { width: '100%', height: '100%', ...maxWidthMaybe, ...maxHeightMaybe };
return (
<div
ref={element => {
this.element = element;
}}
style={style}
>
{width !== 0 && height !== 0 ? <Component {...props} /> : null}
</div>
);
}
}
LazyLoadWithDimensionsComponent.displayName = `lazyLoadWithDimensions(${Component.displayName ||
Component.name})`;
return LazyLoadWithDimensionsComponent;
};

View file

@ -2,6 +2,10 @@
# yarn lockfile v1
"@mapbox/polyline@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@mapbox/polyline/-/polyline-1.0.0.tgz#b6f1c3cf61f8dddcf9ac6dce0b2e50e5f4e965bc"
"@types/node@*":
version "9.6.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-9.6.0.tgz#d3480ee666df9784b1001a1872a2f6ccefb6c2d7"