@teipublisher/pb-components
Version:
Collection of webcomponents underlying TEI Publisher
558 lines (518 loc) • 16.3 kB
JavaScript
import { LitElement, html, css } from 'lit-element';
import '@lrnwebcomponents/es-global-bridge';
import { pbMixin } from './pb-mixin.js';
import { resolveURL } from './utils.js';
import { get as i18n } from './pb-i18n.js';
import './pb-map-layer.js';
import './pb-map-icon.js';
/**
* A wrapper component for [leaflet](https://leafletjs.com/) displaying a map.
*
* The map layers displayed can be configured via nested `pb-map-layer` (see docs) elements,
* icons via `pb-map-icon`.
*
* @slot - may contain a series of `pb-map-layer` configurations
* @fires pb-leaflet-marker-click - Fires event to be processed by the map upon click
* @fires pb-update-map - When received, redraws the map to fit markers passed in with the event.
* Event details should include an array of locations, see `pb-geolocation` event below.
* @fires pb-update - When received, redraws the map to show markers for all pb-geolocation elements found in the content of the pb-view
* @fires pb-geolocation - When received, focuses the map on the geocoordinates passed in with the event.
* The event details should include an object:
* ```
* {
* coordinates: {
* latitude: Number,
* longitude: Number
* },
* label: string - the label to show on mouseover,
* zoom: Number - fixed zoom level to zoom to,
* fitBounds: Boolean - if true, recompute current zoom level to show all markers
* }
* ```
* @fires pb-geocode - emitted if geocoding is enabled and the user searches or selects a location from the map
*/
export class PbLeafletMap extends pbMixin(LitElement) {
static get properties() {
return {
...super.properties,
latitude: {
type: Number,
},
longitude: {
type: Number,
},
zoom: {
type: Number,
},
crs: {
type: String,
},
/**
* If set, the map will automatically zoom so it can fit all the markers
*/
fitMarkers: {
type: Boolean,
attribute: 'fit-markers',
},
/**
* If set, combine markers into clusters if they are located too close together
* to display as single markers
*/
cluster: {
type: Boolean,
},
/**
* Limits up to which zoom level markers are arranged into clusters.
* Using a higher zoom level here will result in more markers to be shown.
*
* Requires `cluster` option to be enabled.
*/
disableClusteringAt: {
type: Number,
attribute: 'disable-clustering-at',
},
/**
* If enabled, the map will not automatically scroll to the coordinates received via `pb-geolocation`
*/
noScroll: {
type: Boolean,
attribute: 'no-scroll',
},
accessToken: {
type: String,
attribute: 'access-token',
},
/**
* If enabled, the map will remain invisible until an event is received from `pb-geolocation`.
* In this case the map also offers a close button to hide it again.
*/
toggle: {
type: Boolean,
},
imagesPath: {
type: String,
attribute: 'images-path',
},
cssPath: {
type: String,
attribute: 'css-path',
},
/**
* Enables geocoding: an additional control will allow users to search for a place.
* Reverse geocoding is also possible: clicking on the map while pressing ctrl or cmd
* will request information about the current location.
*
* In both cases, a `pb-geocode` event will be emitted containing additional information
* about the place in the event details (see demo).
*
* For lookups the free OSM/Nominatim service is used.
*/
geoCoding: {
type: Boolean,
attribute: 'geo-coding',
},
_map: {
type: Object,
},
};
}
constructor() {
super();
this.latitude = 51.505;
this.longitude = -0.09;
this.zoom = 15;
this.crs = 'EPSG3857';
this.accessToken = '';
this.imagesPath = '../images/leaflet/';
this.cssPath = '../css/leaflet';
this.toggle = false;
this.noScroll = false;
this.disabled = true;
this.cluster = false;
this.fitMarkers = false;
this.disableClusteringAt = null;
this._icons = {};
this.geoCoding = false;
}
connectedCallback() {
super.connectedCallback();
this._layers = this.querySelectorAll('pb-map-layer');
this._markers = this.querySelectorAll('pb-map-icon');
/**
* Custom event which passes an array of pb-geolocation within event details
* @param {{ detail: any[]; }} ev
*/
this.subscribeTo('pb-update-map', ev => {
this._markerLayer.clearLayers();
/**
* @param {{ latitude: any; longitude: any; label: any; }} loc
*/
/**
* @param {{ latitude: any; longitude: any; label: any; }} loc
*/
ev.detail.forEach(loc => {
const marker = L.marker([loc.latitude, loc.longitude]);
if (loc.label) {
marker.bindTooltip(loc.label);
}
marker.addEventListener('click', () => {
this.emitTo('pb-leaflet-marker-click', { element: loc });
});
marker.bindTooltip(loc.label);
this.setMarkerIcon(marker);
this._markerLayer.addLayer(marker);
});
this._fitBounds();
});
/**
* React to pb-update event triggered by a pb-view
*
* @param {{ detail: { root: { querySelectorAll: (arg0: string) => any[]; }; }; }} ev
*/
this.subscribeTo('pb-update', ev => {
this._markerLayer.clearLayers();
const locations = ev.detail.root.querySelectorAll('pb-geolocation');
/**
* @param {{ latitude: any; longitude: any; }} loc
*/
locations.forEach(loc => {
const coords = L.latLng(loc.latitude, loc.longitude);
const marker = L.marker(coords).addTo(this._markerLayer);
if (loc.label) {
marker.bindTooltip(loc.label);
}
if (loc.popup) {
marker.bindPopup(loc.popup);
}
marker.addEventListener('click', () => {
this.emitTo('pb-leaflet-marker-click', { element: loc });
});
this.setMarkerIcon(marker);
});
this._fitBounds();
});
/**
* React to events send by pb-geolocation
*
* @param {{ detail: { coordinates: { latitude: number; longitude: number; }, label: string; }; }} ev
*/
this.subscribeTo('pb-geolocation', ev => {
if (ev.detail.coordinates) {
this.latitude = ev.detail.coordinates.latitude;
this.longitude = ev.detail.coordinates.longitude;
if (ev.detail.clear) {
this._markerLayer.clearLayers();
}
if (!this._hasMarker(this.latitude, this.longitude)) {
const marker = L.marker([this.latitude, this.longitude]);
marker.addEventListener('click', () => {
this.emitTo('pb-leaflet-marker-click', ev.detail);
});
if (ev.detail.label) {
marker.bindTooltip(ev.detail.label);
}
if (ev.detail.popup) {
marker.bindPopup(ev.detail.popup);
}
this.setMarkerIcon(marker);
marker.addTo(this._markerLayer);
if (ev.detail.fitBounds) {
this._fitBounds();
}
console.log('<pb-leaflet-map> added marker');
} else {
console.log('<pb-leaflet-map> Marker already added to map');
}
if (this.toggle) {
this.disabled = false;
}
const activateMarker = ev.detail.event;
this._locationChanged(this.latitude, this.longitude, ev.detail.zoom, activateMarker);
}
});
}
/**
* The underlying leafletjs map. Can be used for custom scripts.
*
* Will be null until the component is fully loaded. Listen to `pb-ready` on the component to
* be sure it has initialized.
*/
get map() {
return this._map;
}
setMarkerIcon(layer) {
if (this._icons && this._icons.default) {
layer.setIcon(this._icons.default);
}
}
firstUpdated() {
if (!this.toggle) {
this.disabled = false;
}
if (window.L !== undefined) {
this._initMap();
return;
}
window.ESGlobalBridge.requestAvailability();
const leafletPath = resolveURL('../lib/leaflet-src.js');
const pluginPath = resolveURL('../lib/leaflet.markercluster-src.js');
const geoCodingPath = resolveURL('../lib/Control.Geocoder.min.js');
window.ESGlobalBridge.instance.load('leaflet', leafletPath).then(() => {
window.ESGlobalBridge.instance.load('plugin', pluginPath).then(() => {
if (this.geoCoding) {
window.ESGlobalBridge.instance
.load('geocoding', geoCodingPath)
.then(this._initMap.bind(this));
} else {
this._initMap();
}
});
});
}
render() {
const cssPath = resolveURL(this.cssPath);
return html`
<link rel="Stylesheet" href="${cssPath}/leaflet.css" />
<link rel="Stylesheet" href="${cssPath}/MarkerCluster.Default.css" />
${this.geoCoding
? html`<link rel="Stylesheet" href="${cssPath}/Control.Geocoder.css" />`
: null}
<div id="map" style="height: 100%; width: 100%"></div>
`;
}
static get styles() {
return css`
:host {
display: block;
}
:host([disabled]) {
visibility: hidden;
}
.close {
border-radius: 4px;
background-color: #fff;
color: inherit;
padding: 8px;
font-size: 18px;
font-weight: bold;
text-decoration: none;
cursor: pointer;
}
`;
}
_initMap() {
if (this._map) {
return;
}
L.Icon.Default.imagePath = resolveURL(this.imagesPath);
const crs = L.CRS[this.crs] || L.CRS.EPSG3857;
this._map = L.map(this.shadowRoot.getElementById('map'), {
zoom: this.zoom,
center: L.latLng(this.latitude, this.longitude),
crs,
});
this._configureLayers();
this._configureMarkers();
if (this.cluster) {
const options = {};
if (this.disableClusteringAt) {
options.disableClusteringAtZoom = this.disableClusteringAt;
}
this._markerLayer = L.markerClusterGroup(options);
} else {
this._markerLayer = L.layerGroup();
}
this._markerLayer.addTo(this._map);
this.signalReady();
L.control.scale().addTo(this._map);
if (this.toggle) {
let container;
L.Control.CloseButton = L.Control.extend({
options: {
position: 'topright',
},
onAdd: map => {
container = L.DomUtil.create('div');
container.className = 'close';
container.innerHTML = 'X';
L.DomEvent.on(container, 'click', this._hide.bind(this));
return container;
},
onRemove: map => {
L.DomEvent.off(container, 'click', this._hide.bind(this));
},
});
L.control.closeButton = options => new L.Control.CloseButton(options);
L.control.closeButton({ position: 'topright' }).addTo(this._map);
}
this._configureGeoCoding();
}
_configureGeoCoding() {
if (!this.geoCoding) {
return;
}
const geocoder = L.Control.Geocoder.nominatim({
geocodingQueryParams: {
'accept-language': 'en',
},
});
const control = L.Control.geocoder({
defaultMarkGeocode: false,
geocoder,
placeholder: i18n('search.search'),
suggestMinLength: 3,
});
control.on('markgeocode', e => {
const { geocode } = e;
const options = {
coordinates: {
longitude: geocode.center.lng,
latitude: geocode.center.lat,
},
name: geocode.name,
label: geocode.html,
properties: geocode.properties,
};
this.emitTo('pb-geocode', options);
});
control.addTo(this._map);
this._map.on('click', e => {
if (e.originalEvent.ctrlKey || e.originalEvent.metaKey) {
e.originalEvent.stopPropagation();
geocoder.reverse(e.latlng, this._map.options.crs.scale(this._map.getZoom()), results => {
const geocode = results[0];
const options = {
coordinates: {
longitude: e.latlng.lng,
latitude: e.latlng.lat,
},
name: geocode.name,
label: geocode.html,
properties: geocode.properties,
};
this.emitTo('pb-geocode', options);
});
}
});
}
_configureMarkers() {
if (this._markers.length === 0) {
return;
}
this._icons = {};
this._markers.forEach(config => {
if (config.iconUrl) {
this._icons[config.name] = L.icon(config.options);
}
});
}
_configureLayers() {
if (this._layers.length === 0) {
// configure a default layer
L.tileLayer(
'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token={accessToken}',
{
attribution:
'© <a href="https://www.mapbox.com/about/maps/">Mapbox</a> © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> <strong><a href="https://www.mapbox.com/map-feedback/" target="_blank">Improve this map</a></strong>',
maxZoom: 18,
zoomOffset: -1,
tileSize: 512,
accessToken: this.accessToken,
},
).addTo(this._map);
return;
}
const layers = L.control.layers(null, null, { collapsed: true });
this._layers.forEach(config => {
let layer;
switch (config.type) {
case 'geojson':
config.data().then(data => {
layer = L.geoJSON([data]);
this._addLayer(config, layer, layers);
});
break;
default:
layer = L.tileLayer(config.url, config.options);
this._addLayer(config, layer, layers);
break;
}
});
// only show layer control if there's more than one layer
if (this._layers.length > 1) {
layers.addTo(this._map);
}
this._layers = null;
}
_addLayer(config, layer, layers) {
if (config.show) {
layer.addTo(this._map);
}
if (config.label) {
if (config.base) {
layers.addBaseLayer(layer, config.label);
} else {
layers.addOverlay(layer, config.label);
}
}
}
_fitBounds() {
if (!this.fitMarkers) {
return;
}
const bounds = L.latLngBounds();
let len = 0;
this._markerLayer.eachLayer(layer => {
bounds.extend(layer.getLatLng());
len += 1;
});
if (len === 0) {
this._map.fitWorld();
} else if (len === 1) {
this._map.fitBounds(bounds, { maxZoom: this.zoom });
} else {
this._map.fitBounds(bounds);
}
}
_locationChanged(lat, long, zoom, setActive) {
if (this._map) {
const coords = L.latLng([lat, long]);
this._markerLayer.eachLayer(layer => {
if (layer.getLatLng().equals(coords)) {
if (zoom && !this.noScroll) {
layer.openTooltip();
this._map.setView(coords, zoom);
} else if (this.cluster) {
this._markerLayer.zoomToShowLayer(layer, () => layer.openTooltip());
} else {
layer.openTooltip();
if (zoom) {
this._map.setView(coords, zoom);
} else {
this._map.panTo(coords);
}
}
if (setActive && this._icons && this._icons.active) {
layer.setIcon(this._icons.active);
}
} else if (this._icons && this._icons.default && layer.getIcon() !== this._icons.default) {
layer.setIcon(this._icons.default);
}
});
}
}
_hasMarker(lat, long) {
const coords = L.latLng([lat, long]);
let found = null;
this._markerLayer.eachLayer(layer => {
if (layer instanceof L.Marker && layer.getLatLng().equals(coords)) {
found = layer;
}
});
return found;
}
_hide() {
this.disabled = true;
}
}
customElements.define('pb-leaflet-map', PbLeafletMap);