src/components/MapPage/Map.js
import React, { Component } from "react";
import { connect } from 'react-redux';
import _ from "lodash";
import mapboxgl from '!mapbox-gl'; // eslint-disable-line import/no-webpack-loader-syntax
import "mapbox-gl/dist/mapbox-gl.css";
import Fab from "@material-ui/core/Fab";
import GpsFixed from "@material-ui/icons/GpsFixed";
import GpsOff from "@material-ui/icons/GpsOff";
import AddAPhotoIcon from "@material-ui/icons/AddAPhoto";
import Dehaze from "@material-ui/icons/Dehaze";
import { withStyles } from "@material-ui/core/styles";
import { gtagEvent } from "gtag.js";
import "./Map.scss";
import MapLocation from "types/MapLocation";
import config from "custom/config";
import { GeolocationContext } from "store/GeolocationContext";
import placeholderImage from "custom/assets/images/logo.svg";
const styles = (theme) => ({
location: {
position: "absolute",
top: theme.spacing(2),
right: theme.spacing(2),
},
expansionDetails: {
padding: 0,
"overflow-wrap": "break-word",
"word-wrap": "break-word",
},
camera: {
position: "absolute",
bottom: theme.spacing(1),
right: theme.spacing(1),
borderRadius: "10px",
height: "36px",
fontSize: "0.8rem",
padding: "0 12px",
width: "unset",
transition: "bottom 0.3s",
},
cameraHigh: {
bottom: theme.spacing(6.5),
},
burger: {
position: "absolute",
top: theme.spacing(3),
left: theme.spacing(2),
margin: -theme.spacing(2),
padding: theme.spacing(2),
},
});
const UPDATE_URL_COORDS_DELAY = 1000;
class Map extends Component {
static contextType = GeolocationContext;
constructor(props) {
super(props);
this.map = {
getCenter: () => ({ lat: 0, lng: 0 }),
getZoom: () => 0,
loaded: () => false,
flyTo: () => false,
};
this.renderedThumbnails = {};
this.navControl = null;
this.updatingCoordinates = {};
}
async componentDidMount() {
mapboxgl.accessToken = config.MAPBOX_TOKEN;
const mapLocation = this.props.mapLocation;
const zoom = mapLocation.zoom;
const center = mapLocation.getCenter();
this.map = new mapboxgl.Map({
container: "map", // container id
style: config.MAP_SOURCE,
center: center, // starting position [lng, lat]
zoom: zoom, // starting zoom
attributionControl: false,
});
this.navControl = new mapboxgl.NavigationControl({
showCompass: false,
});
if (this.props.embeddable) {
this.map.addControl(this.navControl, "top-left");
}
this.map.addControl(
new mapboxgl.AttributionControl({
compact: true,
customAttribution: config.MAP_ATTRIBUTION,
}),
"bottom-left"
);
this.map.on("load", () => {
// TODO: inform parent that the map has been loaded
this.addFeaturesToMap(this.props.geojson);
});
// this.callHandlerCoordinates();
this.map.on("moveend", (e) => {
this.callHandlerCoordinates();
});
this.map.on("render", "unclustered-point", (e) => {
this.updateRenderedThumbails(e.features);
});
this.map.on("mouseenter", "clusters", () => {
this.map.getCanvas().style.cursor = "pointer";
});
this.map.on("mouseleave", "clusters", () => {
this.map.getCanvas().style.cursor = "";
});
this.map.on("click", "clusters", (e) => {
gtagEvent("Cluster Clicked", "Map");
const features = this.map.queryRenderedFeatures(e.point, {
layers: ["clusters"],
});
const clusterId = features[0].properties.cluster_id;
this.map
.getSource("data")
.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) {
return;
} else {
this.flyTo(
new MapLocation({
latitude: features[0].geometry.coordinates[1],
longitude: features[0].geometry.coordinates[0],
zoom
})
);
}
});
});
}
flyTo = (mapLocation) => {
this.map.flyTo({
center: [mapLocation.longitude, mapLocation.latitude],
zoom: mapLocation.zoom,
});
};
calcMapLocation = () =>
new MapLocation({
latitude: this.map.getCenter().lat,
longitude: this.map.getCenter().lng,
zoom: this.map.getZoom()
});
componentDidUpdate(prevProps) {
const mapLocation = this.props.mapLocation;
const prevMapLocation = prevProps.mapLocation;
if (mapLocation && !mapLocation.isEqual(prevMapLocation)) {
this.flyTo(mapLocation);
}
// if the geofeatures have changed
if (
this.map.loaded() &&
!_.isEqual(this.props.geojson, prevProps.geojson)
) {
this.addFeaturesToMap(this.props.geojson);
}
if (this.props.embeddable !== prevProps.embeddable) {
if (this.props.embeddable) {
this.map.addControl(this.navControl, "top-left");
} else {
this.map.removeControl(this.navControl);
}
}
}
addFeaturesToMap = (geojson) => {
const dataSource = this.map.getSource("data");
if (dataSource) {
dataSource.setData(geojson);
} else {
this.map.addSource("data", {
type: "geojson",
data: geojson,
cluster: true,
clusterMaxZoom: 14, // Max zoom to cluster points on
clusterRadius: 48, // Radius of each cluster when clustering points (defaults to 50)
});
this.map.addLayer({
id: "clusters",
type: "circle",
source: "data",
filter: ["has", "point_count"],
paint: {
// Use step expressions (https://www.mapbox.com/mapbox-gl-js/style-spec/#expressions-step)
// with six steps to implement six types of circles:
"circle-color": [
"step",
["get", "point_count"],
"#89b685",
50,
"#E8DB52",
100,
"#FEB460",
300,
"#FF928B",
1000,
"#E084B4",
5000,
"#8097BF",
],
"circle-radius": [
"step",
["get", "point_count"],
17,
50,
18,
100,
19,
300,
20,
1000,
21,
5000,
22,
],
},
});
this.map.addLayer({
id: "cluster-count",
type: "symbol",
source: "data",
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["Source Sans Pro Bold"],
"text-size": 15,
},
});
this.map.addLayer({
id: "unclustered-point",
type: "circle",
source: "data",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-radius": 0,
},
});
}
};
callLocationChangeHandler = () => {
clearTimeout(this.updatingCoordinates);
delete this.updatingCoordinates;
this.props.handleMapLocationChange(this.calcMapLocation());
};
callHandlerCoordinates = () => {
clearTimeout(this.updatingCoordinates);
this.updatingCoordinates = setTimeout(() => {
this.callLocationChangeHandler();
}, UPDATE_URL_COORDS_DELAY);
};
updateRenderedThumbails = (visibleFeatures) => {
_.forEach(this.renderedThumbnails, (thumbnailUrl, id) => {
const exists = !!_.find(
visibleFeatures,
(feature) => feature.properties.id === id
);
// if it !exist => remove marker object - delete key from dictionary
if (!exists) {
this.renderedThumbnails[id].remove();
delete this.renderedThumbnails[id];
}
});
visibleFeatures.forEach((_feature) => {
// for some reason, mapbox cluster changes the properties.
const feature = _.find(this.props.geojson.features, (f) => f.properties.id === _feature.properties.id);
if (!this.renderedThumbnails[feature.properties.id]) {
//create a div element - give attributes
const el = document.createElement("div");
el.className = "marker";
el.id = feature.properties.id;
// unpublished photos are still displayed
if (!feature.properties.published) {
el.className += " private";
}
// own photos have a small details
const ownerId = _.get(this.props, "user.id");
if (ownerId && ownerId === feature.properties.owner_id) {
el.className += " own";
}
el.style.backgroundImage = `url(${feature.properties.thumbnail}), url(${placeholderImage}) `;
el.addEventListener("click", () => {
gtagEvent("Photo Clicked", "Map", feature.properties.id);
// this.callLocationChangeHandler();
this.props.handlePhotoClick(feature);
});
//create marker
const latitude = feature.properties.location._lat;
const longitude = feature.properties.location._long;
const coordinates = [longitude, latitude];
const marker = new mapboxgl.Marker(el)
.setLngLat(coordinates)
.addTo(this.map);
//save the marker object to the renderedThumbnails dictionary
this.renderedThumbnails[feature.properties.id] = marker;
}
});
};
componentWillUnmount() {
if (this.map.remove) {
this.map.remove();
}
}
handlerLocation() {
clearTimeout(this.updatingCoordinates);
delete this.updatingCoordinates;
this.props.handleLocationClick();
}
render() {
const { classes } = this.props;
const gpsOffline = !this.context.geolocation.online;
const gpsDisabled = !this.context.geolocation.updated;
return (
<div
className={"geovation-map"}
style={{ visibility: this.props.visible ? "visible" : "hidden" }}
>
<div id="map" className="map"></div>
<Fab
className={classes.location}
size="small"
onClick={() => this.handlerLocation()}
disabled={gpsDisabled}
>
{gpsOffline ? <GpsOff /> : <GpsFixed />}
</Fab>
{!this.props.embeddable && (
<div>
<Fab
className={`${classes.camera} ${
this.props.online && this.props.geojson
? ""
: classes.cameraHigh
}`}
color="secondary"
onClick={this.props.handleCameraClick}
>
<span style={{ marginRight: "3px" }}>Add a photo</span>
<AddAPhotoIcon />
</Fab>
<Dehaze
className={classes.burger}
onClick={this.props.toggleLeftDrawer(true)}
/>
</div>
)}
</div>
);
}
}
const mapStateToProps = (state) => ({
user: state.user,
geojson: state.geojson,
online: state.online,
});
export default connect(mapStateToProps)(withStyles(styles)(Map));