Source: map-common/src/main/resources/META-INF/resources/js/MapBase.es.js

/**
 * Copyright (c) 2000-present Liferay, Inc. All rights reserved.
 *
 * This library is free software; you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation; either version 2.1 of the License, or (at your option)
 * any later version.
 *
 * This library is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 */

import {buildFragment} from 'frontend-js-web';
import State, {Config} from 'metal-state';

import GeoJSONBase from './GeoJSONBase.es';
import MarkerBase from './MarkerBase.es';
import {isSubsetOf} from './validators.es';

/**
 * HTML template string used for generating the home button that is
 * used for centering the map at the user location or the original position.
 * @review
 * @type {string}
 */
const TPL_HOME_BUTTON = `
	<button class='btn btn-secondary home-button'>
		<i class='glyphicon glyphicon-screenshot'></i>
	</button>
`;

/**
 * HTML template string used for generating the search box that is used
 * for looking for map locations.
 * @review
 * @type {string}
 */
const TPL_SEARCH_BOX = `
	<div class='col-md-6 search-controls'>
		<input class='search-input' placeholder='' type='text' />
	<div>
`;

/**
 * Object that will hold callbacks waiting for being executed
 * when maps are created.
 * @review
 * @see MapBase.register()
 * @see MapBase.get()
 */
const pendingCallbacks = {};

/**
 * MapBase
 * Each instance represents the map object itself, and adds
 * all necesary listeners and object specified in the given
 * configuration. This class is the core of the module, and
 * will create instances of all the other objects, including
 * the native maps implemented by inheriting classes.
 * @abstract
 * @review
 */
class MapBase extends State {

	/**
	 * MapBase constructor
	 * @param  {Array} args List of arguments to be sent to State constructor
	 * @review
	 */
	constructor(...args) {
		super(...args);

		this._customControls = {};
		this._dialog = null;
		this._eventHandlers = [];
		this._geoJSONLayer = null;
		this._geocoder = null;
		this._geolocationMarker = null;
		this._map = null;
		this._originalPosition = null;

		this._handleGeoJSONLayerFeatureClicked = this._handleGeoJSONLayerFeatureClicked.bind(
			this
		);
		this._handleGeoJSONLayerFeaturesAdded = this._handleGeoJSONLayerFeaturesAdded.bind(
			this
		);
		this._handleGeoLocationMarkerDragended = this._handleGeoLocationMarkerDragended.bind(
			this
		);
		this._handleHomeButtonClicked = this._handleHomeButtonClicked.bind(
			this
		);
		this._handlePositionChanged = this._handlePositionChanged.bind(this);
		this._handleSearchButtonClicked = this._handleSearchButtonClicked.bind(
			this
		);

		this.on('positionChange', this._handlePositionChanged);

		const geolocation =
			this.position && this.position.location
				? this.position.location
				: {};

		if (!geolocation.lat && !geolocation.lng) {
			Liferay.Util.getGeolocation(
				(lat, lng) => {
					this._initializeLocation({lat, lng});
				},
				() => {
					this.zoom = 2;
					this._initializeLocation({lat: 0, lng: 0});
				}
			);
		}
		else {
			this._initializeLocation(geolocation);
		}
	}

	/**
	 * Destroys the existing _geoJSONLayer and _customControls[SEARCH]
	 * @review
	 * @see MapBase._initializeMap()
	 * @see MapBase._createCustomControls()
	 */
	destructor() {
		if (this._geoJSONLayer) {
			this._geoJSONLayer.dispose();

			this._geoJSONLayer = null;
		}

		if (
			this._customControls &&
			this._customControls[this.constructor.CONTROLS.SEARCH]
		) {
			this._customControls[this.constructor.CONTROLS.SEARCH].dispose();

			this._customControls[this.constructor.CONTROLS.SEARCH] = null;
		}
	}

	/**
	 * @protected
	 * @review
	 *
	 * Add event listeners to:
	 * @see this._geoJSONLayer
	 * @see this._geolocationMarker
	 * @see this._customControls
	 *
	 * All added listeners are implemented as binded methods:
	 * @see this._handleGeoJSONLayerFeaturesAdded
	 * @see this._handleGeoJSONLayerFeatureClicked
	 * @see this._handleGeoLocationMarkerDragended
	 * @see this._handleHomeButtonClicked
	 * @see this._handleSearchButtonClicked
	 */
	_bindUIMB() {
		if (this._geoJSONLayer) {
			this._geoJSONLayer.on(
				'featuresAdded',
				this._handleGeoJSONLayerFeaturesAdded
			);

			this._geoJSONLayer.on(
				'featureClick',
				this._handleGeoJSONLayerFeatureClicked
			);
		}

		if (this._geolocationMarker) {
			this._geolocationMarker.on(
				'dragend',
				this._handleGeoLocationMarkerDragended
			);
		}

		if (this._customControls) {
			const homeControl = this._customControls[
				this.constructor.CONTROLS.HOME
			];
			const searchControl = this._customControls[
				this.constructor.CONTROLS.SEARCH
			];

			if (homeControl) {
				homeControl.addEventListener(
					'click',
					this._handleHomeButtonClicked
				);
			}

			if (searchControl) {
				searchControl.on('search', this._handleSearchButtonClicked);
			}
		}
	}

	/**
	 * Creates existing custom controls and stores them inside _customControls.
	 * It only adds the home button and the search box if the corresponding
	 * flags are activated inside MapBase.controls.
	 * @protected
	 * @review
	 */
	_createCustomControls() {
		const controls = this.controls || [];
		const customControls = {};

		if (controls.indexOf(this.constructor.CONTROLS.HOME) !== -1) {
			const homeControl = buildFragment(TPL_HOME_BUTTON).querySelector(
				'.btn.btn-secondary.home-button'
			);
			customControls[this.constructor.CONTROLS.HOME] = homeControl;
			this.addControl(
				homeControl,
				this.constructor.POSITION.RIGHT_BOTTOM
			);
		}

		if (
			controls.indexOf(this.constructor.CONTROLS.SEARCH) !== -1 &&
			this.constructor.SearchImpl
		) {
			const searchControl = buildFragment(TPL_SEARCH_BOX).querySelector(
				'div.col-md-6.search-controls'
			);
			customControls[
				this.constructor.CONTROLS.SEARCH
			] = new this.constructor.SearchImpl({
				inputNode: searchControl.querySelector('input'),
			});
			this.addControl(searchControl, this.constructor.POSITION.TOP_LEFT);
		}

		this._customControls = customControls;
	}

	/**
	 * Creates a new map for the given location and controlsConfig.
	 * @abstract
	 * @param {Object} location
	 * @param {Object} controlsConfig
	 * @protected
	 * @return {Object} Created map
	 * @review
	 */
	_createMap(
		/* eslint-disable no-unused-vars */
		location,
		controlsConfig
		/* eslint-enable no-unused-vars */
	) {
		throw new Error('This method must be implemented');
	}

	/**
	 * Returns an object with the control configuration associated to the
	 * existing controls (MapBase.controls).
	 * @protected
	 * @return {Object} Object with the control options with the following shape:
	 *  for each control, there is a corresponding [control] attribute with a
	 *  boolean value indicating if there is a configuration for the control. If
	 *  true, where will be a [control]Options attribute with the configuration
	 *  content.
	 * @review
	 */
	_getControlsConfig() {
		const config = {};
		const availableControls = this.controls.map((item) => {
			return typeof item === 'string' ? item : item.name;
		});

		Object.keys(this.constructor.CONTROLS_MAP).forEach((key) => {
			const controlIndex = availableControls.indexOf(key);
			const value = this.constructor.CONTROLS_MAP[key];

			if (controlIndex > -1) {
				const controlConfig = this.controls[controlIndex];

				if (
					controlConfig &&
					typeof controlConfig === 'object' &&
					controlConfig.cfg
				) {
					config[`${value}Options`] = controlConfig.cfg;
				}

				config[value] = controlIndex !== -1;
			}
		});

		return config;
	}

	/**
	 * If there is an existing this._dialog, it returns it.
	 * Otherwise, if the MapBase.DialogImpl class has been set, it creates
	 * a new instance with the existing map and returns it.
	 * @protected
	 * @return {MapBase.DialogImpl|null}
	 * @review
	 */
	_getDialog() {
		if (!this._dialog && this.constructor.DialogImpl) {
			this._dialog = new this.constructor.DialogImpl({
				map: this._map,
			});
		}

		return this._dialog;
	}

	/**
	 * If there is an existing this._geocoder, it returns it.
	 * Otherwise, if the MapBase.GeocoderImpl class has been set, it creates
	 * a new instance and returns it.
	 * @protected
	 * @return {MapBase.GeocoderImpl|null}
	 * @review
	 */
	_getGeocoder() {
		if (!this._geocoder && this.constructor.GeocoderImpl) {
			this._geocoder = new this.constructor.GeocoderImpl();
		}

		return this._geocoder;
	}

	/**
	 * Event handler executed when any feature is added to the existing.
	 * GeoJSONLayer. It will update the current position if necesary.
	 * @param {{ features: Array<Object> }} param0 Array of features that
	 *  will be processed.
	 * @protected
	 * @review
	 * @see MapBase.getBounds()
	 * @see MapBase.position
	 */
	_handleGeoJSONLayerFeaturesAdded({features}) {
		const bounds = this.getBounds();

		const locations = features.map((feature) =>
			feature.getGeometry().get()
		);

		if (locations.length > 1) {
			locations.forEach((location) => bounds.extend(location));
		}
		else {
			this.position = {location: locations[0]};
		}
	}

	/**
	 * Event handler executed when a GeoJSONLayer feature is clicked. It
	 * simple propagates the event with the given feature.
	 * @param {{ feature: Object }} param0 Feature to be propagated.
	 * @protected
	 * @review
	 */
	_handleGeoJSONLayerFeatureClicked({feature}) {
		this.emit('featureClick', {feature});
	}

	/**
	 * Event handler executed when the geolocation marker has been dragged to
	 * a new position. It updates the instance position.
	 * @param {{ location: Object }} param0 New marker location.
	 * @protected
	 * @review
	 * @see MapBase._getGeoCoder()
	 * @see MapBase.position
	 */
	_handleGeoLocationMarkerDragended({location}) {
		this._getGeocoder().reverse(location, ({data}) => {
			this.position = data;
		});
	}

	/**
	 * Event handler executed when the home button GeoJSONLayer feature is
	 * clicked. It resets the instance position to _originalPosition and stops
	 * the event propagation.
	 * @param {Event} event Click event.
	 * @review
	 * @see MapBase.position
	 * @see MapBase._originalPosition
	 */
	_handleHomeButtonClicked(event) {
		event.preventDefault();

		this.position = this._originalPosition;
	}

	/**
	 * Event handler executed when the user position changes. It centers the map
	 * and, if existing, updates the _geolocationMarker position.
	 * @param {{ newVal: { location: Object } }} param0 New location information.
	 * @review
	 * @see MapBase.setCenter()
	 * @see MapBase._geolocationMarker
	 */
	_handlePositionChanged({newVal: {location}}) {
		this.setCenter(location);

		if (this._geolocationMarker) {
			this._geolocationMarker.setPosition(location);
		}
	}

	/**
	 * Event handler executed when the search button GeoJSONLayer feature has
	 * been clicked. It updates the instance position.
	 * @param {{ position: Object }} param0 New position.
	 * @review
	 * @see MapBase.position
	 */
	_handleSearchButtonClicked({position}) {
		this.position = position;
	}

	/**
	 * If this.data has a truthy value and this._geoJSONLayer has been
	 * set, it tries to parse geoJSON information with _geoJSONLayer.addData()
	 * @protected
	 * @review
	 * @see this._geoJSONLayer
	 * @see this.data
	 */
	_initializeGeoJSONData() {
		if (this.data && this._geoJSONLayer) {
			this._geoJSONLayer.addData(this.data);
		}
	}

	/**
	 * Initializes the instance map using the given location and, if
	 * this.geolocation property is true, tries to get the location using
	 * the geocoder.
	 * @param {Object} location Location object user for map initialization.
	 * @protected
	 * @review
	 * @see this._initializeMap()
	 * @see this._getGeocoder()
	 */
	_initializeLocation(geolocation) {
		const geocoder = this._getGeocoder();

		if (this.geolocation && geocoder) {
			geocoder.reverse(geolocation, ({data}) =>
				this._initializeMap(data)
			);
		}
		else {
			this._initializeMap({location: geolocation});
		}
	}

	/**
	 * Creates a new map with the given position and initialize the map
	 * controls, loads the geoJSONData and, if geolocation is active, creates
	 * a marker for it.
	 * @param {Object} position Initial position added to the map.
	 * @protected
	 * @review
	 * @see MapBase._getControlsConfig()
	 * @see MapBase._createMap()
	 * @see MapBase.GeoJSONImpl
	 * @see MapBase.addMarker()
	 * @see MapBase._createCustomControls()
	 * @see MapBase._bindUIMB()
	 * @see MapBase._initializeGeoJSONData()
	 */
	_initializeMap(position) {
		const controlsConfig = this._getControlsConfig();
		const geolocation = position.location;

		this._originalPosition = position;
		this._map = this._createMap(geolocation, controlsConfig);

		if (
			this.constructor.GeoJSONImpl &&
			this.constructor.GeoJSONImpl !== GeoJSONBase
		) {
			this._geoJSONLayer = new this.constructor.GeoJSONImpl({
				map: this._map,
			});
		}

		if (this.geolocation) {
			this._geolocationMarker = this.addMarker(geolocation);
		}

		this.position = position;

		this._createCustomControls();
		this._bindUIMB();
		this._initializeGeoJSONData();
	}

	/**
	 * Adds a new control to the interface at the given position.
	 * @param {Object} control Native control object
	 * @param {MapBase.POSITION} position Position defined in MapBase class
	 * @review
	 */
	addControl(
		/* eslint-disable no-unused-vars */
		control,
		position
		/* eslint-enable no-unused-vars */
	) {
		throw new Error('This method must be implemented');
	}

	/**
	 * Returns the map bounds.
	 * @abstract
	 * @return {Object} Map bounds
	 * @review
	 */
	getBounds() {
		throw new Error('This method must be implemented');
	}

	/**
	 * Centers the map on the given location.
	 * @abstract
	 * @param {Object} location
	 * @review
	 */
	setCenter(
		/* eslint-disable no-unused-vars */
		location
		/* eslint-enable no-unused-vars */
	) {
		throw new Error('This method must be implemented');
	}

	/**
	 * If the class MapBase.MarkerImpl has been specified, creates an instance
	 * of this class with the given location and the existing this._map object
	 * and returns it.
	 * @param {Object} location Location object used for the marker position
	 * @return {MapBase.MarkerImpl}
	 * @review
	 */
	addMarker(location) {
		let marker;

		if (
			this.constructor.MarkerImpl &&
			this.constructor.MarkerImpl !== MarkerBase
		) {
			marker = new this.constructor.MarkerImpl({
				location,
				map: this._map,
			});
		}

		return marker;
	}

	/**
	 * Returns the stored map
	 * @return {Object}
	 * @review
	 * @see MapBase._initializeMap()
	 */
	getNativeMap() {
		return this._map;
	}

	/**
	 * Adds a listener for the given event using the given context. This
	 * methods uses MetalJS' functionality, but overrides the context binding
	 * in order to avoid breaking changes with the old implementation.
	 * @param {string} eventName Event name that will be listened.
	 * @param {function} callback Callback executed when the event fires.
	 * @param {Object} [context] Optional context that will be used for binding
	 *  the callback when specified.
	 * @return {*} Result of the State.on method.
	 * @review
	 * @see State.on
	 */
	on(eventName, callback, context) {
		let boundCallback = callback;

		if (context) {
			boundCallback = callback.bind(context);
		}

		return super.on(eventName, boundCallback);
	}

	/**
	 * Opens a dialog if this_getDialog() returns a valid object.
	 * @param {*} dialogConfig Dialog configuration that will be sent to the
	 *  dialog.open() method.
	 * @review
	 * @see MapBase._getDialog()
	 */
	openDialog(dialogConfig) {
		const dialog = this._getDialog();

		if (dialog) {
			dialog.open(dialogConfig);
		}
	}

	/**
	 * Setter called everytime the position attribute is changed.
	 * @param {Object} position New position
	 * @return {Object} The given position object
	 * @review
	 */
	setPosition(position) {
		this.emit('positionChange', {
			newVal: {
				address: position.address,
				location: position.location,
			},
		});

		return position;
	}
}

/**
 * Registers the given callback to be executed when the map identified
 * by the given id has been created. If the map has already been created, it
 * is executed immediatly.
 * @param {string} id Id of the map that needs to be created
 * @param {function} callback Callback being executed
 * @review
 */
MapBase.get = function (id, callback) {
	const map = Liferay.component(id);

	if (map) {
		callback(map);
	}
	else {
		const idPendingCallbacks = pendingCallbacks[id] || [];

		idPendingCallbacks.push(callback);

		pendingCallbacks[id] = idPendingCallbacks;
	}
};

/**
 * Registers the given map with the given id, and executes all existing
 * callbacks associated with the id. Then it clears the list of callbacks.
 * @param {string} id Id of the map that has been created
 * @param {Object} map Map that has been created
 * @param {string} portletId Id of the portlet that registers the map
 * @review
 */
MapBase.register = function (id, map, portletId) {
	const componentConfig = portletId ? {portletId} : {destroyOnNavigate: true};

	Liferay.component(id, map, componentConfig);

	const idPendingCallbacks = pendingCallbacks[id];

	if (idPendingCallbacks) {
		idPendingCallbacks.forEach((callback) => callback(map));

		idPendingCallbacks.length = 0;
	}
};

/**
 * Class that will be used for creating dialog objects.
 * @review
 */
MapBase.DialogImpl = null;

/**
 * Class that will be used for parsing geoposition information.
 * @review
 */
MapBase.GeocoderImpl = null;

/**
 * Class that will be used for creating GeoJSON instances.
 * This class must be replaced by another one extending GeoJSONBase.
 * @review
 */
MapBase.GeoJSONImpl = GeoJSONBase;

/**
 * Class that will be used for creating map markers.
 * This class must be replaced by another one extending MarkerBase.
 * @review
 */
MapBase.MarkerImpl = MarkerBase;

/**
 * Class that will be used for creating an instance of searchbox.
 * This class must be replaced by another one extending MarkerBase.
 * @review
 */
MapBase.SearchImpl = null;

/**
 * List of controls that maybe shown inside the rendered map.
 * @review
 */
MapBase.CONTROLS = {
	ATTRIBUTION: 'attribution',
	GEOLOCATION: 'geolocation',
	HOME: 'home',
	OVERVIEW: 'overview',
	PAN: 'pan',
	ROTATE: 'rotate',
	SCALE: 'scale',
	SEARCH: 'search',
	STREETVIEW: 'streetview',
	TYPE: 'type',
	ZOOM: 'zoom',
};

/**
 * Control mapping that should be overriden by child classes.
 * @review
 */
MapBase.CONTROLS_MAP = {};

/**
 * Available map positions.
 * @review
 */
MapBase.POSITION = {
	BOTTOM: 11,
	BOTTOM_CENTER: 11,
	BOTTOM_LEFT: 10,
	BOTTOM_RIGHT: 12,
	CENTER: 13,
	LEFT: 5,
	LEFT_BOTTOM: 6,
	LEFT_CENTER: 4,
	LEFT_TOP: 5,
	RIGHT: 7,
	RIGHT_BOTTOM: 9,
	RIGHT_CENTER: 8,
	RIGHT_TOP: 7,
	TOP: 2,
	TOP_CENTER: 2,
	TOP_LEFT: 1,
	TOP_RIGHT: 3,
};

/**
 * Position mapping that should be overriden by child classes.
 * @review
 */
MapBase.POSITION_MAP = {};

/**
 * State definition.
 * @review
 * @static
 * @type {!Object}
 */
MapBase.STATE = {

	/**
	 * DOM node selector identifying the element that will be used
	 * for rendering the map
	 * @review
	 * @type {string}
	 */
	boundingBox: Config.string().value(''),

	/**
	 * List of controls that will be shown on the map.
	 * The full control list is kept inside MapBase.CONTROLS.
	 * @review
	 * @type {Array<string>}
	 */
	controls: Config.validator(
		isSubsetOf(Object.values(MapBase.CONTROLS))
	).value([
		MapBase.CONTROLS.PAN,
		MapBase.CONTROLS.TYPE,
		MapBase.CONTROLS.ZOOM,
	]),

	/**
	 * Data that will be parsed as GeoJSONData
	 * @review
	 * @type {Object}
	 */
	data: Config.object(),

	/**
	 * If true, the geolocation API will be used (if implemented)
	 * @review
	 * @type {boolean}
	 */
	geolocation: Config.bool().value(false),

	/**
	 * Position being shown on the map. This value will be updated
	 * if the position is changed internally.
	 * @review
	 * @type {{ lat: number, lng: number }}
	 */
	position: Config.shapeOf({
		location: Config.shapeOf({
			lat: Config.number().value(0),
			lng: Config.number().value(0),
		}),
	})
		.value({
			location: {lat: 0, lng: 0},
		})
		.setter('setPosition'),

	/**
	 * Zoom being used on the map.
	 * @review
	 * @type {number}
	 */
	zoom: Config.number().value(11),
};

Liferay.MapBase = MapBase;

export default MapBase;
export {MapBase};