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 (
+
+ );
+ }
+}
+
+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"