Geovation/photos

View on GitHub
src/components/MapPage/Map.js

Summary

Maintainability
C
1 day
Test Coverage
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));