diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d5b6d65..b5c8a2f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/package.json b/package.json index 02233907..e1b18c3d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Map/images/marker-32x32.png b/public/static/icons/map-marker-32x32.png similarity index 100% rename from src/components/Map/images/marker-32x32.png rename to public/static/icons/map-marker-32x32.png diff --git a/src/components/Map/DynamicMap.js b/src/components/Map/DynamicMap.js new file mode 100644 index 00000000..7dc8bbde --- /dev/null +++ b/src/components/Map/DynamicMap.js @@ -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 = ; + + const circleProps = { + options: coordinatesConfig.circleOptions, + radius: coordinatesConfig.coordinateOffset, + center, + }; + + const circle = ; + + return ( + + {coordinatesConfig.fuzzy ? circle : marker} + + ); +}); + +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; diff --git a/src/components/Map/Map.js b/src/components/Map/Map.js index 942ef90d..5f972215 100644 --- a/src/components/Map/Map.js +++ b/src/components/Map/Map.js @@ -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 = ; - - const circleProps = { - options: coordinatesConfig.circleOptions, - radius: coordinatesConfig.coordinateOffset, - center, - }; - - const circle = ; - - return ( - - {coordinatesConfig.fuzzy ? circle : marker} - - ); -}); - 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 ? ( - + ) : useStaticMap ? ( + + ) : ( + } mapElement={
} center={centerLocationForGoogleMap} @@ -92,8 +56,6 @@ export class Map extends Component { address={address} coordinatesConfig={coordinatesConfig} /> - ) : ( -
); } } @@ -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; diff --git a/src/components/Map/StaticMap.js b/src/components/Map/StaticMap.js new file mode 100644 index 00000000..694e5109 --- /dev/null +++ b/src/components/Map/StaticMap.js @@ -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 ( + {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' }); diff --git a/src/config.js b/src/config.js index 240cfb32..f594a2b4 100644 --- a/src/config.js +++ b/src/config.js @@ -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, }; diff --git a/src/containers/ListingPage/ListingPage.css b/src/containers/ListingPage/ListingPage.css index 1a56843c..7a745f7a 100644 --- a/src/containers/ListingPage/ListingPage.css +++ b/src/containers/ListingPage/ListingPage.css @@ -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) { diff --git a/src/containers/ListingPage/SectionMapMaybe.js b/src/containers/ListingPage/SectionMapMaybe.js index ac6e015c..9a946305 100644 --- a/src/containers/ListingPage/SectionMapMaybe.js +++ b/src/containers/ListingPage/SectionMapMaybe.js @@ -29,7 +29,7 @@ const SectionMapMaybe = props => {
- +
); diff --git a/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap b/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap index a3f6f6ae..d44699f5 100644 --- a/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap +++ b/src/containers/SearchPage/__snapshots__/SearchPage.test.js.snap @@ -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, diff --git a/src/util/contextHelpers.js b/src/util/contextHelpers.js index a945220e..40f99e1e 100644 --- a/src/util/contextHelpers.js +++ b/src/util/contextHelpers.js @@ -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 ( +
{ + this.element = element; + }} + style={style} + > + {width !== 0 && height !== 0 ? : null} +
+ ); + } + } + + LazyLoadWithDimensionsComponent.displayName = `lazyLoadWithDimensions(${Component.displayName || + Component.name})`; + + return LazyLoadWithDimensionsComponent; +}; diff --git a/yarn.lock b/yarn.lock index 329a764c..ce2e37b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"