Skip to content

Commit

Permalink
Create MarkableMap component
Browse files Browse the repository at this point in the history
  • Loading branch information
Nick Maher committed Mar 10, 2017
1 parent ce51f4b commit ec7adda
Show file tree
Hide file tree
Showing 13 changed files with 558 additions and 2 deletions.
4 changes: 4 additions & 0 deletions components/Map/InteractiveMarker.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.root {
position: absolute;
transform: translate(-50%, -100%);
}
34 changes: 34 additions & 0 deletions components/Map/InteractiveMarker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React, { Component, PropTypes } from 'react';

import css from './InteractiveMarker.css';

export default class InteractiveMarker extends Component {
static propTypes = {
active: PropTypes.bool,
id: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.number,
]).isRequired,
MarkerComponent: PropTypes.func.isRequired,
onClick: PropTypes.func,
props: PropTypes.object,
};

handleClick = (e) => {
const { onClick, id } = this.props;
onClick(e, id);
};

render() {
const { MarkerComponent, active, props } = this.props;
return (
<div className={ css.root }>
<MarkerComponent
{ ...props }
active={ active }
onClick={ this.handleClick }
/>
</div>
);
}
}
10 changes: 10 additions & 0 deletions components/Map/InteractiveMarker.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import React from 'react';
import { render } from 'react-dom';
import InteractiveMarker from './InteractiveMarker';

const MarkerComponent = () => <button />;

it('renders without crashing', () => {
const div = document.createElement('div');
render(<InteractiveMarker id={ 1 } MarkerComponent={ MarkerComponent } />, div);
});
75 changes: 74 additions & 1 deletion components/Map/Map.story.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,80 @@
import React, { Component } from 'react';
import { storiesOf } from '@kadira/storybook';
import MarkableMap from './MarkableMap';
import BaseMap from './BaseMap';
import Marker from './SpaceMarker';

const generateMarkers = (number = 1) => {
const markers = [];

for (let i = 0; i < number; i += 1) {
const lng = -0.09 + ((Math.random() - Math.random()) * Math.random());
const lat = 51.505 + ((Math.random() - Math.random()) * Math.random());

markers.push({
id: i,
lngLat: [lng, lat],
props: {
price: '£322',
priceUnit: '/day',
location: 'Shoreditch',
city: 'London',
size: '1000 sqft',
name: 'Bold Street Shop',
images: [{
src: 'https://source.unsplash.com/random/500x503',
alt: 'hello',
}, {
src: 'https://source.unsplash.com/random/500x500',
alt: 'hello2',
}, {
src: 'https://source.unsplash.com/random/500x502',
alt: 'hello',
}, {
src: 'https://source.unsplash.com/random/500x501',
alt: 'hello2',
}],
href: '#',
},
});
}
return markers;
};

class TestMap extends Component {
constructor(props) {
super(props);

this.state = {
markers: generateMarkers(10),
};
}

toggleMarkers = () => {
this.setState({ markers: generateMarkers(Math.floor(Math.random() * 20)) });
}

render() {
const { markers } = this.state;
return (
<div style={ { height: '93vh' } }>
<button onClick={ this.toggleMarkers }>Randomise</button>
<MarkableMap
markers={ markers }
MarkerComponent={ Marker }
onClick={ actionWithComplexArgs('map clicked') }
onMoveEnd={ actionWithComplexArgs('map moved') }
autoFit
/>
</div>
);
}
}

storiesOf('Map', module)
.add('Default', () => (
<div style={ { height: '96vh' } }><BaseMap /></div>
))
))
.add('MarkableMap', () => (
<TestMap />
));
8 changes: 8 additions & 0 deletions components/Map/MarkableMap.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.marker {
position: absolute;
z-index: 1;
}

.markerActive {
z-index: 2;
}
148 changes: 148 additions & 0 deletions components/Map/MarkableMap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import React, { Component, PropTypes } from 'react';
/* eslint-disable camelcase */
import {
unstable_renderSubtreeIntoContainer as renderSubtreeIntoContainer,
unmountComponentAtNode,
} from 'react-dom';
/* eslint-enable camelcase */
import isEqual from 'lodash/fp/isEqual';
import uniqueId from 'lodash/fp/uniqueId';
import differenceBy from 'lodash/fp/differenceBy';
import cx from 'classnames';

import minLngLatBounds from '../../utils/minLngLatBounds/minLngLatBounds';
import mapboxgl from '../../utils/mapboxgl/mapboxgl';
import InteractiveMarker from './InteractiveMarker';
import BaseMap from './BaseMap';

import css from './MarkableMap.css';

export default class MarkableMap extends Component {
static propTypes = {
markers: PropTypes.array,
MarkerComponent: PropTypes.func.isRequired,
autoFit: PropTypes.bool,
};

static defaultProps = {
markers: [],
autoFit: false,
};

constructor(props) {
super(props);
this.id = uniqueId('map_');
}

state = {
activeMarkerId: null,
}

componentDidMount() {
const { markers, autoFit } = this.props;
markers.forEach(this.renderMarker);
if (autoFit) this.fitMarkers();
}

componentDidUpdate(prevProps) {
const { markers: prevMarkers } = prevProps;
const { markers, autoFit } = this.props;

const removedMarkers = differenceBy('id')(prevMarkers)(markers);
removedMarkers.forEach(this.removeMarker);
markers.forEach(this.renderMarker);

const markersMoved = markers.some((marker) => {
const prevMarker = prevMarkers.find(prev => prev.id === marker.id);
return !prevMarker || !isEqual(prevMarker.lngLat, marker.lngLat);
});
const markerChange = removedMarkers.length || markersMoved;

if (autoFit && markerChange) this.fitMarkers();
}

componentWillUnmount() {
Object.keys(this.mapboxMarkers).forEach((id) => {
this.removeMarker({ id });
});
}

getMaboxGL = () => this.map.getMaboxGL();

handleMarkerClick = (e, id) => {
const marker = this.mapboxMarkers[id];
const markerLngLat = marker.getLngLat();
const zoom = this.getMaboxGL().getZoom();

const nextLat = markerLngLat.lat + (80 / Math.pow(2, zoom));
const nextCenter = new mapboxgl.LngLat(markerLngLat.lng, nextLat).wrap();

this.getMaboxGL().easeTo({ center: nextCenter });
this.setState({ activeMarkerId: id });
}

handleMapClick = ({ originalEvent }) => {
if (originalEvent.target !== this.getMaboxGL().getCanvas()) return;
this.setState({ activeMarkerId: null });
}

mapboxMarkers = {};

removeMarker = ({ id }) => {
const marker = this.mapboxMarkers[id];
unmountComponentAtNode(marker.getElement());
marker.remove();
delete this.mapboxMarkers[id];
if (this.state.activeMarkerId === id) this.setState({ activeMarkerId: null });
}

fitMarkers = () => {
const markers = Object.keys(this.mapboxMarkers).map(id => this.mapboxMarkers[id]);
if (!markers.length) return;

const destucturedMarkers = markers.map(marker => marker.getLngLat().toArray());

this.getMaboxGL().fitBounds(
minLngLatBounds(destucturedMarkers),
{ padding: 20, offset: [0, 20], maxZoom: 16 },
);
}

renderMarker = ({ lngLat, props, id }) => {
const { activeMarkerId } = this.state;
const { MarkerComponent } = this.props;

let marker;
if (this.mapboxMarkers[id]) {
marker = this.mapboxMarkers[id];
marker.setLngLat(lngLat);
} else {
marker = new mapboxgl.Marker().setLngLat(lngLat).addTo(this.getMaboxGL());
this.mapboxMarkers[id] = marker;
}

const active = activeMarkerId === id;
const element = marker.getElement();
element.className = cx(css.marker, active ? css.markerActive : null);

renderSubtreeIntoContainer(
this,
<InteractiveMarker
active={ active }
id={ id }
key={ `${this.id}-${id}-marker` }
MarkerComponent={ MarkerComponent }
onClick={ this.handleMarkerClick }
props={ props }
/>,
element
);

return marker;
}

render() {
const { markers: _markers, MarkerComponent: _MarkerComponent, ...rest } = this.props;
return <BaseMap ref={ (c) => { this.map = c; } } { ...rest } onClick={ this.handleMapClick } />;
}
}
Loading

0 comments on commit ec7adda

Please sign in to comment.