Merge pull request #683 from sharetribe/coordinate-obfuscation

Optionally obfuscate coordinates and show a circle on the map
This commit is contained in:
Kimmo Puputti 2018-02-02 14:14:13 +02:00 committed by GitHub
commit 4ad89cfde6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 201 additions and 26 deletions

View file

@ -1,10 +1,11 @@
import React from 'react';
import Map from './Map';
import { types as sdkTypes } from '../../util/sdkLoader';
import config from '../../config';
const { LatLng } = sdkTypes;
export const Empty = {
export const WithMarker = {
component: props => (
<div style={{ height: 400 }}>
<Map {...props} />
@ -12,7 +13,26 @@ export const Empty = {
),
props: {
center: new LatLng(60.16502999999999, 24.940064399999983),
obfuscatedCenter: new LatLng(60.16502999999999, 24.940064399999983),
address: 'Sharetribe',
zoom: 22,
},
};
export const WithObfuscatedLocation = {
component: props => (
<div style={{ height: 400 }}>
<Map {...props} />
</div>
),
props: {
center: new LatLng(60.16502999999999, 24.940064399999983),
obfuscatedCenter: new LatLng(60.16502999999999, 24.940064399999983),
address: 'Sharetribe',
zoom: 14,
coordinatesConfig: {
...config.coordinates,
fuzzy: true,
},
},
};

View file

@ -1,8 +1,10 @@
import React, { Component } from 'react';
import { string, number } from 'prop-types';
import { withGoogleMap, GoogleMap, Marker } from 'react-google-maps';
import { string, number, object } from 'prop-types';
import { withGoogleMap, GoogleMap, Marker, Circle } from 'react-google-maps';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
import config from '../../config';
import CustomMarker from './images/marker-32x32.png';
import css from './Map.css';
@ -11,9 +13,9 @@ import css from './Map.css';
* It handles some of the google map initialization states.
*/
const MapWithGoogleMap = withGoogleMap(props => {
const { center, zoom, address } = props;
const { center, zoom, address, coordinatesConfig } = props;
const customMarker = {
const markerIcon = {
url: CustomMarker,
// This marker is 32 pixels wide by 32 pixels high.
size: new window.google.maps.Size(32, 32),
@ -23,6 +25,16 @@ const MapWithGoogleMap = withGoogleMap(props => {
anchor: new window.google.maps.Point(16, 32),
};
const marker = <Marker position={center} icon={markerIcon} title={address} />;
const circleProps = {
options: coordinatesConfig.circleOptions,
radius: coordinatesConfig.circleRadius,
center,
};
const circle = <Circle {...circleProps} />;
return (
<GoogleMap
defaultZoom={zoom}
@ -37,7 +49,7 @@ const MapWithGoogleMap = withGoogleMap(props => {
fullscreenControl: true,
}}
>
<Marker position={center} icon={customMarker} title={address} />
{coordinatesConfig.fuzzy ? circle : marker}
</GoogleMap>
);
});
@ -51,10 +63,30 @@ export class Map extends Component {
}
render() {
const { className, rootClassName, mapRootClassName, address, center, zoom } = this.props;
const {
className,
rootClassName,
mapRootClassName,
address,
center,
obfuscatedCenter,
zoom,
coordinatesConfig,
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const mapClasses = mapRootClassName || css.mapRoot;
const centerLocationForGoogleMap = { lat: center.lat, lng: center.lng };
if (coordinatesConfig.fuzzy && !obfuscatedCenter) {
throw new Error(
'Map: obfuscatedCenter prop is required when config.coordinates.fuzzy === true'
);
}
if (!coordinatesConfig.fuzzy && !center) {
throw new Error('Map: center prop is required when config.coordinates.fuzzy === false');
}
const location = coordinatesConfig.fuzzy ? obfuscatedCenter : center;
const centerLocationForGoogleMap = { lat: location.lat, lng: location.lng };
return (
<MapWithGoogleMap
@ -63,25 +95,30 @@ export class Map extends Component {
center={centerLocationForGoogleMap}
zoom={zoom}
address={address}
coordinatesConfig={coordinatesConfig}
/>
);
}
}
Map.defaultProps = {
className: '',
className: null,
rootClassName: null,
mapRootClassName: null,
zoom: 11,
address: '',
zoom: config.coordinates.fuzzy ? config.coordinates.fuzzyDefaultZoomLevel : 11,
coordinatesConfig: config.coordinates,
};
Map.propTypes = {
className: string,
rootClassName: string,
mapRootClassName: string,
address: string.isRequired,
center: propTypes.latlng.isRequired,
address: string,
center: propTypes.latlng,
obfuscatedCenter: propTypes.latlng,
zoom: number,
coordinatesConfig: object,
};
export default Map;

View file

@ -1,12 +1,14 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { arrayOf, bool, func, number, string, object, shape } from 'prop-types';
import { withGoogleMap, GoogleMap } from 'react-google-maps';
import classNames from 'classnames';
import { groupBy, isEqual, reduce } from 'lodash';
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 css from './SearchMap.css';
@ -67,6 +69,21 @@ const reducedToArray = mapListings => {
return reduce(mapListings, (acc, listing) => acc.concat([listing]), []);
};
const withCoordinatesObfuscated = listings => {
return listings.map(listing => {
const { id, attributes, ...rest } = listing;
const geolocation = obfuscatedCoordinates(attributes.geolocation, id ? id.uuid : null);
return {
id,
...rest,
attributes: {
...attributes,
geolocation,
},
};
});
};
/**
* MapWithGoogleMap uses withGoogleMap HOC.
* It handles some of the google map initialization states.
@ -214,15 +231,20 @@ export class SearchMapComponent extends Component {
rootClassName,
center,
isOpenOnModal,
listings,
listings: originalListings,
mapRootClassName,
onCloseAsModal,
onIdle,
zoom,
coordinatesConfig,
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const mapClasses = mapRootClassName || css.mapRoot;
const listings = coordinatesConfig.fuzzy
? withCoordinatesObfuscated(originalListings)
: originalListings;
// container element listens clicks so that opened SearchMapInfoCard can be closed
/* eslint-disable jsx-a11y/no-static-element-interactions */
return (
@ -261,10 +283,9 @@ SearchMapComponent.defaultProps = {
rootClassName: null,
useLocationSearchBounds: true,
zoom: 11,
coordinatesConfig: config.coordinates,
};
const { arrayOf, bool, func, number, string } = PropTypes;
SearchMapComponent.propTypes = {
bounds: propTypes.latlngBounds,
center: propTypes.latlng,
@ -277,6 +298,12 @@ SearchMapComponent.propTypes = {
rootClassName: string,
useLocationSearchBounds: bool, // eslint-disable-line react/no-unused-prop-types
zoom: number,
coordinatesConfig: shape({
fuzzy: bool.isRequired,
fuzzyDefaultZoomLevel: number.isRequired,
circleRadius: number.isRequired,
circleOptions: object.isRequired,
}),
};
const SearchMap = SearchMapComponent;

View file

@ -238,6 +238,24 @@ const siteFacebookPage = 'https://www.facebook.com/Sharetribe/';
// You should create one to track social sharing in Facebook
const facebookAppId = null;
const coordinates = {
// If true, obfuscate the coordinates of the listings that are shown
// on a map.
fuzzy: process.env.REACT_APP_FUZZY_COORDINATES || false,
fuzzyDefaultZoomLevel: 14,
// When fuzzy === true, a circle is shown on the map, the radius and
// the style can be configured here.
circleRadius: 500,
circleOptions: {
fillColor: '#c0392b',
fillOpacity: 0.2,
strokeColor: '#c0392b',
strokeWeight: 0.5,
},
};
// NOTE: only expose configuration that should be visible in the
// client side, don't add any server secrets in this file.
const config = {
@ -264,6 +282,7 @@ const config = {
facebookAppId,
sentryDsn,
usingSSL,
coordinates,
custom,
};

View file

@ -575,7 +575,11 @@ export class ListingPageComponent extends Component {
</div>
<SectionRulesMaybe publicData={publicData} />
<SectionMapMaybe geolocation={geolocation} publicData={publicData} />
<SectionMapMaybe
geolocation={geolocation}
publicData={publicData}
listingId={currentListing.id}
/>
<div className={css.reviewsContainer}>
<h2 className={css.reviewsHeading}>

View file

@ -1,36 +1,52 @@
import React from 'react';
import { object, string } from 'prop-types';
import { string } from 'prop-types';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
import { obfuscatedCoordinates } from '../../util/maps';
import { Map } from '../../components';
import config from '../../config';
import css from './ListingPage.css';
const SectionMapMaybe = props => {
const { className, rootClassName, geolocation, publicData } = props;
const { className, rootClassName, geolocation, publicData, listingId } = props;
if (!geolocation) {
return null;
}
const address = publicData.location ? publicData.location.address : '';
const classes = classNames(rootClassName || css.locationContainer, className);
return geolocation ? (
const mapProps = config.coordinates.fuzzy
? { obfuscatedCenter: obfuscatedCoordinates(geolocation, listingId ? listingId.uuid : null) }
: { address, center: geolocation };
return (
<div className={classes}>
<h2 className={css.locationTitle}>
<FormattedMessage id="ListingPage.locationTitle" />
</h2>
<div className={css.map}>
<Map center={geolocation} address={address} />
<Map {...mapProps} />
</div>
</div>
) : null;
);
};
SectionMapMaybe.defaultProps = { className: null, rootClassName: null };
SectionMapMaybe.defaultProps = {
rootClassName: null,
className: null,
geolocation: null,
listingId: null,
};
SectionMapMaybe.propTypes = {
className: string,
rootClassName: string,
geolocation: propTypes.latlng.isRequired,
publicData: object.isRequired,
className: string,
geolocation: propTypes.latlng,
listingId: propTypes.uuid,
};
export default SectionMapMaybe;

View file

@ -334,6 +334,11 @@ exports[`ListingPage matches snapshot 1`] = `
"lng": 60,
}
}
listingId={
UUID {
"uuid": "listing1",
}
}
publicData={Object {}}
rootClassName={null}
/>

View file

@ -115,6 +115,19 @@ exports[`SearchPageComponent matches snapshot 1`] = `
}
}
className={null}
coordinatesConfig={
Object {
"circleOptions": Object {
"fillColor": "#c0392b",
"fillOpacity": 0.2,
"strokeColor": "#c0392b",
"strokeWeight": 0.5,
},
"circleRadius": 500,
"fuzzy": false,
"fuzzyDefaultZoomLevel": 14,
}
}
isOpenOnModal={false}
listings={Array []}
mapRootClassName={null}

34
src/util/maps.js Normal file
View file

@ -0,0 +1,34 @@
import { random, memoize, round } from 'lodash';
import { types as sdkTypes } from './sdkLoader';
const { LatLng } = sdkTypes;
const obfuscatedCoordinatesImpl = latlng => {
const { lat, lng } = latlng;
const threshold = 0.01;
const newLat = round(lat + random(-1 * threshold, threshold), 5);
const newLng = round(lng + random(-1 * threshold, threshold), 5);
return new LatLng(newLat, newLng);
};
const obfuscationKeyGetter = (latlng, cacheKey) => cacheKey;
const memoizedObfuscatedCoordinatesImpl = memoize(obfuscatedCoordinatesImpl, obfuscationKeyGetter);
/**
* Make the given coordinates randomly a little bit different.
*
* @param {LatLng} latlng coordinates
* @param {String?} cacheKey if given, the results are memoized and
* the same coordinates are returned for the same key as long as the
* cache isn't cleared (e.g. with page refresh). This results in
* e.g. same listings always getting the same obfuscated coordinates
* if the listing id is used as the cache key.
*
* @return {LatLng} obfuscated coordinates
*/
export const obfuscatedCoordinates = (latlng, cacheKey = null) => {
return cacheKey
? memoizedObfuscatedCoordinatesImpl(latlng, cacheKey)
: obfuscatedCoordinatesImpl(latlng);
};