@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
1,002 lines (908 loc) • 30.7 kB
JavaScript
import "./Map.css";
import {
VECTOR_STYLES, TILES_PICTURES_ZOOM, getThumbGif, RASTER_LAYER_ID, combineStyles,
getMissingLayerStyles, isLabelLayer, getUserLayerId, getUserSourceId, isNullCoordinates,
} from "../../utils/map";
import { COLORS } from "../../utils/utils";
import MarkerBaseSVG from "../../img/marker.svg";
import MarkerSelectedSVG from "../../img/marker_blue.svg";
import ArrowFlatSVG from "../../img/arrow_flat.svg";
import Arrow360SVG from "../../img/arrow_360.svg";
// MapLibre imports
import "maplibre-gl/dist/maplibre-gl.css";
import maplibregl from "!maplibre-gl"; // DO NOT REMOVE THE "!": bundled builds breaks otherwise !!!
import maplibreglWorker from "maplibre-gl/dist/maplibre-gl-csp-worker";
import * as pmtiles from "pmtiles";
maplibregl.workerClass = maplibreglWorker;
maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile);
/**
* Map is the component showing pictures and sequences geolocation.
*
* Note that all functions of [MapLibre GL JS class Map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/) are also available.
*
* A more complete version of Map (with filters & themes) is available through [MapMore class](#Panoramax.components.ui.MapMore)
*
* ⚠️ This class doesn't inherit from [EventTarget](https://developer.mozilla.org/fr/docs/Web/API/EventTarget), so it doesn't have `addEventListener` and `dispatchEvent` functions.
* It uses instead [`on`](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#on) and `fire` functions from MapLibre Map class.
* `fire` function doesn't take directly [`Event`](https://developer.mozilla.org/fr/docs/Web/API/Event) objects, but a string and object data.
* A shorthand `addEventListener` function is added for simpler usage.
* @class Panoramax.components.ui.Map
* @extends [maplibregl.Map](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/)
* @param {Panoramax.components.core.Basic} parent The parent view
* @param {Element} container The DOM element to create into
* @param {object} [options] The map options (any of [MapLibre GL settings](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/) or any supplementary option defined here)
* @param {object} [options.raster] The MapLibre raster source for aerial background. This must be a JSON object following [MapLibre raster source definition](https://maplibre.org/maplibre-style-spec/sources/#raster).
* @param {string} [options.background=streets] Choose default map background to display (streets or aerial, if raster aerial background available). Defaults to street.
* @param {string} [options.attributionControl.customAttribution] To override default map attribution.
* @param {boolean} [options.picMarkerDraggable] To make the picture marker draggable, default to false.
* @fires Panoramax.components.ui.Map#background-changed
* @fires Panoramax.components.ui.Map#users-changed
* @fires Panoramax.components.ui.Map#sequence-hover
* @fires Panoramax.components.ui.Map#sequence-click
* @fires Panoramax.components.ui.Map#picture-click
* @fires Panoramax.components.ui.Map#ready
* @example
* const map = new Panoramax.components.ui.Map(viewer, mapNode, {center: {lat: 48.7, lng: -1.7}});
*/
export default class Map extends maplibregl.Map {
constructor(parent, container, options = {}) {
super({
container: container,
style: combineStyles(parent, options),
center: [0,0],
zoom: 0,
maxZoom: 24,
attributionControl: false,
dragRotate: false,
pitchWithRotate: false,
touchZoomRotate: true,
touchPitch: false,
doubleClickZoom: false,
canvasContextAttributes: {
preserveDrawingBuffer: !parent.isWidthSmall(),
},
transformRequest: parent.api._getMapRequestTransform(),
locale: parent._t.maplibre,
hash: false,
...options
});
this._loadMarkerImages();
this._parent = parent;
this._options = options;
this.getContainer().classList.add("pnx-map");
// Disable touch rotate
if(options.touchZoomRotate === undefined) {
this?.touchZoomRotate?.disableRotation();
}
// Handle raster source
if(this._options.raster) {
this._options.background = this._options.background || "streets";
}
this._attribution = new maplibregl.AttributionControl({ compact: false, ...options.attributionControl });
this.addControl(this._attribution);
this._initMapPosition();
// Widgets and markers
this._picMarker = this._getPictureMarker();
this._picMarkerPreview = this._getPictureMarker(false);
// Cache for pictures and sequences thumbnails
this._picThumbUrl = {};
this._seqPictures = {};
// Sequences and pictures per users
this._userLayers = new Set();
// Parent selection
this._parent.addEventListener("select", this.reloadLayersStyles.bind(this));
// Timeout for initial loading
setTimeout(() => {
if(!this.loaded() && this._parent?.loader.isVisible()) {
this._parent.loader.dismiss({}, this._parent._t.map.slow_loading, async () => {
await this._postLoad();
this._parent.loader.dismiss();
});
}
}, 15000);
this.waitForEnoughMapLoaded().then(async () => await this._postLoad());
}
/**
* @private
*/
async _postLoad() {
this.resize();
await this.setVisibleUsers(this._parent.users);
this.reloadLayersStyles();
/**
* Event when map is ready to display.
* This includes Maplibre initial load, enough map data display and styling.
* @event Panoramax.components.ui.Map#ready
* @type {maplibregl.util.evented.Event}
*/
this.fire("ready");
}
/**
* Destroy any form of life in this component
* @memberof Panoramax.components.ui.Map#
*/
destroy() {
this.remove();
delete this._parent;
delete this._options;
delete this._attribution;
delete this._picMarker;
delete this._picMarkerPreview;
delete this._picThumbUrl;
delete this._seqPictures;
delete this._userLayers;
}
/**
* Helper to know when enough map background and Panoramax tiles are loaded for a proper display.
* @returns {Promise} Resolves when enough is loaded
* @memberof Panoramax.components.ui.Map#
*/
waitForEnoughMapLoaded() {
return new Promise((resolve) => {
let nbBgTiles = 0;
let nbFgTiles = 0;
let nbLoadedBgTiles = 0;
let nbLoadedFgTiles = 0;
const onSourceDataLoading = e => {
if(e.dataType === "source" && e.tile) {
if(e.sourceId.startsWith("geovisio")) {
nbFgTiles++;
}
else {
nbBgTiles++;
}
}
};
const onSourceData = e => {
if(e.dataType === "source" && e.tile) {
if(e.sourceId.startsWith("geovisio")) {
nbLoadedFgTiles++;
if(e.isSourceLoaded) { nbLoadedFgTiles = nbFgTiles; }
}
else {
nbLoadedBgTiles++;
if(e.isSourceLoaded) { nbLoadedBgTiles = nbBgTiles; }
}
}
checkEnoughLoaded();
};
const checkEnoughLoaded = () => {
if(nbLoadedBgTiles / nbBgTiles >= 0.75 && (nbFgTiles === 0 || nbLoadedFgTiles / nbFgTiles >= 0.75)) {
this.off("sourcedata", onSourceData);
this.off("sourcedataloading", onSourceDataLoading);
resolve();
}
};
this.on("sourcedataloading", onSourceDataLoading);
this.on("sourcedata", onSourceData);
});
}
/**
* Sets map view based on returned API bbox (if no precise option given by user).
* @private
* @memberof Panoramax.components.ui.Map#
*/
_initMapPosition() {
if(
isNullCoordinates(this._options.center)
&& (!this._options.zoom || this._options.zoom === 0)
&& (!this._options.hash)
) {
this._parent.onceAPIReady().then(() => {
let bbox = this._parent?.api?.getDataBbox();
if(bbox) {
try {
bbox = new maplibregl.LngLatBounds(bbox);
if(this.loaded()) { this.fitBounds(bbox, { "animate": false }); }
else { this.waitForEnoughMapLoaded().then(() => this.fitBounds(bbox, { "animate": false })); }
}
catch(e) {
console.warn("Received invalid bbox: "+bbox);
}
}
});
}
}
/**
* Load markers into map for use in map layers.
* @private
* @memberof Panoramax.components.ui.Map#
*/
_loadMarkerImages() {
[
{ id: "pnx-marker", img: MarkerBaseSVG },
{ id: "pnx-arrow-flat", img: ArrowFlatSVG },
{ id: "pnx-arrow-360", img: Arrow360SVG },
].forEach(m => {
const img = new Image(64, 64);
img.onload = () => this.addImage(m.id, img);
img.src = m.img;
});
}
/**
* Is Quality Score available in vector tiles ?
* @private
* @memberof Panoramax.components.ui.Map#
*/
_hasQualityScore() {
const fields = this.getStyle()?.metadata?.["panoramax:fields"] || {};
return fields?.pictures?.includes("gps_accuracy") && fields?.pictures?.includes("h_pixel_density");
}
/**
* Are 360/flat pictures stats available in vector tiles for grid layer ?
* @private
* @memberof Panoramax.components.ui.Map#
*/
_hasGridStats() {
const fields = this.getStyle()?.metadata?.["panoramax:fields"] || {};
return fields?.grid?.includes("nb_360_pictures") && fields?.grid?.includes("nb_flat_pictures")
&& fields?.grid?.includes("coef_360_pictures") && fields?.grid?.includes("coef_flat_pictures");
}
/**
* Force refresh of vector tiles data
* @memberof Panoramax.components.ui.Map#
*/
reloadVectorTiles() {
[...this._userLayers].forEach(dl => {
const s = this.getSource(getUserSourceId(dl));
s.setTiles(s.tiles);
});
}
/**
* Check if map offers aerial imagery as well as streets rendering.
* @returns {boolean} True if aerial imagery is available for display
* @memberof Panoramax.components.ui.Map#
*/
hasTwoBackgrounds() {
return this.getLayer(RASTER_LAYER_ID) !== undefined;
}
/**
* Get the currently selected map background
* @returns {string} aerial or streets
* @memberof Panoramax.components.ui.Map#
*/
getBackground() {
if(!this.getLayer(RASTER_LAYER_ID)) {
return "streets";
}
const aerialVisible = this.getLayoutProperty(RASTER_LAYER_ID, "visibility") == "visible";
return aerialVisible ? "aerial" : "streets";
}
/**
* Change the shown background in map.
* @param {string} bg The new background to display (aerial or streets)
* @memberof Panoramax.components.ui.Map#
* @throws {Error} If not aerial imagery is available
*/
setBackground(bg) {
if(!this.getLayer(RASTER_LAYER_ID) && bg === "aerial") { throw new Error("No aerial imagery available"); }
if(this.getLayer(RASTER_LAYER_ID)) {
this.setLayoutProperty(RASTER_LAYER_ID, "visibility", bg === "aerial" ? "visible" : "none");
/**
* Event for map background changes
*
* @event Panoramax.components.ui.Map#background-changed
* @type {maplibregl.util.evented.Event}
* @property {string} [background] The new selected background (aerial, streets)
*/
this.fire("background-changed", { background: bg || "streets" });
}
}
/**
* Get the currently visible users
* @returns {string[]} List of visible users
* @memberof Panoramax.components.ui.Map#
*/
getVisibleUsers() {
return [...this._userLayers].filter(l => (
this.getLayer(getUserLayerId(l, "pictures"))
&& this.getLayoutProperty(getUserLayerId(l, "pictures"), "visibility") === "visible"
));
}
/**
* Wait for a given map layer to be really available.
* @param {string} layerId the layer ID
* @returns {Promise}
* @fulfil {null} When layer is ready.
* @memberof Panoramax.components.ui.Map#
*/
onceLayerReady(layerId) {
if(this.getLayer(layerId)) {
return Promise.resolve();
}
else {
return new Promise(resolve => {
setTimeout(resolve, 250);
}).then(() => this.onceLayerReady(layerId));
}
}
/**
* Make given user layers visible on map, and hide all others (if any)
* @memberof Panoramax.components.ui.Map#
* @param {string|string[]} visibleIds The user layers IDs to display
*/
async setVisibleUsers(visibleIds = []) {
if(typeof visibleIds === "string") { visibleIds = [visibleIds]; }
else if(visibleIds === null) { visibleIds = []; }
// Create any missing user layer
await Promise.all(
visibleIds
.filter(id => id != "" && !this._userLayers.has(id))
.map(id => {
this._createPicturesTilesLayer(id);
return this.onceLayerReady(getUserLayerId(id, "pictures"));
})
);
// Switch visibility
const layersSuffixes = ["pictures", "sequences", "sequences_plus", "grid", "pictures_symbols"];
[...this._userLayers].forEach(l => {
layersSuffixes.forEach(suffix => {
const layerId = getUserLayerId(l, suffix);
if(this.getLayer(layerId)) {
this.setLayoutProperty(layerId, "visibility", visibleIds.includes(l) ? "visible" : "none");
}
});
});
// Force style reload
this.reloadLayersStyles();
/**
* Event for visible users changes
*
* @event Panoramax.components.ui.Map#users-changed
* @type {maplibregl.util.evented.Event}
* @property {string[]} [usersIds] The list of newly selected users
*/
this.fire("users-changed", { usersIds: visibleIds });
}
/**
* Filter the visible data content in all visible map layers
* @param {string} dataType sequences or pictures
* @param {object} filter The MapLibre GL filter rule to apply
* @memberof Panoramax.components.ui.Map#
*/
filterUserLayersContent(dataType, filter) {
[...this._userLayers].forEach(l => {
if(!this.getLayer(getUserLayerId(l, dataType))) {
console.warn("Layer", getUserLayerId(l, dataType), "not ready");
return;
}
this.setFilter(getUserLayerId(l, dataType), filter);
if(dataType === "sequences" && this.getLayer(getUserLayerId(l, "sequences_plus"))) {
this.setFilter(getUserLayerId(l, "sequences_plus"), filter);
}
if(dataType === "pictures" && this.getLayer(getUserLayerId(l, "pictures_symbols"))) {
this.setFilter(getUserLayerId(l, "pictures_symbols"), filter);
}
});
}
/**
* Shows on map a picture position and heading.
*
* If no longitude & latitude are set, marker is removed from map.
* @memberof Panoramax.components.ui.Map#
* @param {number} lon The longitude
* @param {number} lat The latitude
* @param {number} heading The heading
* @param {boolean} [skipCenter=false] Set to true to avoid map centering on marker
* @param {string} [picId=null] The picture Id
*/
displayPictureMarker(lon, lat, heading, skipCenter = false, picId = null) {
this._picMarkerPreview.remove();
// Show marker corresponding to selection
if(lon !== undefined && lat !== undefined) {
this._picMarker
.setLngLat([lon, lat])
.setRotation(heading)
.addTo(this);
this._picMarker.picId = picId ;
}
else {
this._picMarker.remove();
}
// Update map style to see selected sequence
this.reloadLayersStyles();
// Move map to picture coordinates
if(!skipCenter && lon !== undefined && lat !== undefined) {
this.jumpTo({ // No animation to avoid conflict on seq=* load
center: [lon, lat],
zoom: this.getZoom() < TILES_PICTURES_ZOOM+2 ? TILES_PICTURES_ZOOM+2 : this.getZoom(),
});
}
}
/**
* Forces reload of pictures/sequences layer styles.
* This is useful after a map theme change.
* @memberof Panoramax.components.ui.Map#
*/
reloadLayersStyles() {
const updateStyle = (layer, style) => {
[...this._userLayers].forEach(l => {
const layerId = getUserLayerId(l, layer);
if(!this.getLayer(layerId)) {
console.warn("Layer", layerId, "not ready");
return;
}
for(let p in style.layout) {
this.setLayoutProperty(layerId, p, style.layout[p]);
}
for(let p in style.paint) {
this.setPaintProperty(layerId, p, style.paint[p]);
}
});
};
["pictures", "pictures_symbols", "sequences"].forEach(l => {
updateStyle(l, this._getLayerStyleProperties(l));
});
}
/**
* Creates source and layers for pictures and sequences.
* @private
* @memberof Panoramax.components.ui.Map#
* @param {string} id The source and layer ID prefix
*/
async _createPicturesTilesLayer(id) {
this._userLayers.add(id);
const firstLabelLayerId = this.getStyle().layers.find(isLabelLayer);
// Load style from API
if(id !== "geovisio" && !this.getSource(`geovisio_${id}`)) {
const style = await this._parent.api.getUserMapStyle(id);
Object.entries(style.sources).forEach(([sId, s]) => this.addSource(sId, s));
style.layers = style.layers || [];
const layers = style.layers.concat(getMissingLayerStyles(style.sources, style.layers));
layers.filter(l => Object.keys(l).length > 0).forEach(l => this.addLayer(l, firstLabelLayerId?.id));
}
// Map interaction events
// Popup
this._picPreviewTimer = null;
this._picPopup = new maplibregl.Popup({
closeButton: false,
closeOnClick: !this._parent.isWidthSmall(),
offset: 3
});
this._picPopup.on("close", () => { delete this._picPopup._picId; });
// Pictures
const picLayerId = getUserLayerId(id, "pictures");
this.on("mousemove", picLayerId, e => {
this.getCanvas().style.cursor = "pointer";
const eCopy = Object.assign({}, e);
clearTimeout(this._picPreviewTimer);
this._picPreviewTimer = setTimeout(
() => this._attachPreviewToPictures(eCopy, picLayerId),
100
);
});
this.on("mouseleave", picLayerId, () => {
clearTimeout(this._picPreviewTimer);
this.getCanvas().style.cursor = "";
this._picPopup._picId;
this._picPopup.remove();
});
this.on("click", picLayerId, this._onPictureClick.bind(this));
// Sequences
const seqPlusLayerId = getUserLayerId(id, "sequences_plus");
this.on("mousemove", seqPlusLayerId, e => {
this._onSequenceHover(e);
if(this.getZoom() <= TILES_PICTURES_ZOOM+1) {
this.getCanvas().style.cursor = "pointer";
if(e.features[0].properties.id) {
const eCopy = Object.assign({}, e);
clearTimeout(this._picPreviewTimer);
this._picPreviewTimer = setTimeout(
() => this._attachPreviewToPictures(eCopy, seqPlusLayerId),
100
);
}
}
});
this.on("mouseleave", seqPlusLayerId, () => {
clearTimeout(this._picPreviewTimer);
this.getCanvas().style.cursor = "";
this._picPopup._picId;
this._picPopup.remove();
});
this.on("click", seqPlusLayerId, e => {
e.preventDefault();
if(this.getZoom() <= TILES_PICTURES_ZOOM+1) {
this._onSequenceClick(e);
}
});
// Grid
if(id === "geovisio" && this.getLayer("geovisio_grid")) {
this.on("mousemove", "geovisio_grid", e => {
if(this.getZoom() <= TILES_PICTURES_ZOOM+1) {
this.getCanvas().style.cursor = "pointer";
const eCopy = Object.assign({}, e);
clearTimeout(this._picPreviewTimer);
this._picPreviewTimer = setTimeout(
() => this._attachPreviewToPictures(eCopy, "geovisio_grid"),
100
);
}
});
this.on("mouseleave", "geovisio_grid", () => {
clearTimeout(this._picPreviewTimer);
this.getCanvas().style.cursor = "";
this._picPopup._picId;
this._picPopup.remove();
});
this.on("click", "geovisio_grid", e => {
e.preventDefault();
this.flyTo({ center: e.lngLat, zoom: TILES_PICTURES_ZOOM-6 });
});
}
// Map background click
this.on("click", (e) => {
if(e.defaultPrevented === false) {
clearTimeout(this._picPreviewTimer);
this._picPopup.remove();
}
});
}
/**
* MapLibre paint/layout properties for specific layer
* This is useful when selected picture changes to allow partial update
*
* @returns {object} Paint/layout properties
* @private
* @memberof Panoramax.components.ui.Map#
*/
_getLayerStyleProperties(layer) {
if(layer === "pictures_symbols") {
return {
"paint": {},
"layout": {
"icon-image": ["case",
["==", ["get", "id"], this._parent.picture], "",
["==", ["get", "type"], "equirectangular"], "pnx-arrow-360",
"pnx-arrow-flat"
],
"symbol-sort-key": this._getLayerSortStyle(layer),
},
};
}
else {
const prefixes = {
"pictures": "circle",
"sequences": "line",
};
return {
"paint": Object.assign({
[`${prefixes[layer]}-color`]: this._getLayerColorStyle(layer),
}, VECTOR_STYLES[layer.toUpperCase()].paint),
"layout": Object.assign({
[`${prefixes[layer]}-sort-key`]: this._getLayerSortStyle(layer),
}, VECTOR_STYLES[layer.toUpperCase()].layout)
};
}
}
/**
* Retrieve map layer color scheme according to selected theme.
* @private
* @memberof Panoramax.components.ui.Map#
*/
_getLayerColorStyle(layer) {
// Hidden style
const s = ["case",
["==", ["get", "hidden"], true], COLORS.HIDDEN,
["==", ["get", "geovisio:status"], "hidden"], COLORS.HIDDEN,
];
// Selected sequence style
const seqId = this._parent.sequence;
if(layer == "sequences" && seqId) {
s.push(["==", ["get", "id"], seqId], COLORS.SELECTED);
}
else if(layer.startsWith("pictures") && seqId) {
s.push(["in", seqId, ["get", "sequences"]], COLORS.SELECTED);
}
// Classic style
s.push(COLORS.BASE);
return s;
}
/**
* Retrieve map sort key according to selected theme.
* @private
* @memberof Panoramax.components.ui.Map#
*/
_getLayerSortStyle(layer) {
// Values
// - 100 : on top / selected feature
// - 90 : hidden feature
// - 20-80 : custom ranges
// - 10 : basic feature
// - 0 : on bottom / feature with undefined property
// Hidden style
const s = ["case",
["==", ["get", "hidden"], true], 90
];
// Selected sequence style
const seqId = this._parent.sequence;
if(layer == "sequences" && seqId) {
s.push(["==", ["get", "id"], seqId], 100);
}
else if(layer.startsWith("pictures") && seqId) {
s.push(["in", seqId, ["get", "sequences"]], 100);
}
s.push(10);
return s;
}
/**
* Creates popup manager for preview of pictures.
* @private
* @param {object} e The event thrown by MapLibre
* @param {string} from The event source layer
* @memberof Panoramax.components.ui.Map#
*/
_attachPreviewToPictures(e, from) {
let f = e.features.pop();
if(!f || f.properties.id == this._picPopup._picId) { return; }
let coordinates = null;
if(from.endsWith("pictures")) { coordinates = f.geometry.coordinates.slice(); }
else if(e.lngLat) { coordinates = [e.lngLat.lng, e.lngLat.lat]; }
// If no coordinates found, find from geometry (nearest to map center)
if(!coordinates) {
const coords = f.geometry.type === "LineString" ? [f.geometry.coordinates] : f.geometry.coordinates;
let prevDist = null;
const mapBbox = this.getBounds();
const mapCenter = mapBbox.getCenter();
for(let i=0; i < coords.length; i++) {
for(let j=0; j < coords[i].length; j++) {
if(mapBbox.contains(coords[i][j])) {
let dist = mapCenter.distanceTo(new maplibregl.LngLat(...coords[i][j]));
if(prevDist === null || dist < prevDist) {
coordinates = coords[i][j];
prevDist = dist;
}
}
}
}
if(!coordinates) { return; }
}
// Display thumbnail
this._picPopup
.setLngLat(coordinates)
.addTo(this);
// Only show GIF loader if thumbnail is not in browser cache
if(!this._picThumbUrl[f.properties.id]) {
this._picPopup.setDOMContent(getThumbGif(this._parent._t));
}
this._picPopup._loading = f.properties.id;
this._picPopup._picId = f.properties.id;
const displayThumb = thumbUrl => {
if(this._picPopup._loading === f.properties.id) {
delete this._picPopup._loading;
if(thumbUrl) {
let content = document.createElement("img");
content.classList.add("pnx-map-thumb");
content.alt = this._parent._t.map.thumbnail;
let img = new Image();
img.src = thumbUrl;
img.addEventListener("load", () => {
if(f.properties.hidden) {
content.children[0].src = img.src;
}
else {
content.src = img.src;
}
this._picPopup.setDOMContent(content);
});
if(f.properties.hidden) {
const legend = document.createElement("div");
legend.classList.add("pnx-map-thumb-legend");
legend.appendChild(document.createTextNode(this._parent._t.map.not_public));
const container = document.createElement("div");
container.appendChild(content);
container.appendChild(legend);
content = container;
}
}
else {
this._picPopup.remove();
//this._picPopup.setHTML(`<i>${this._parent._t.map.no_thumbnail}</i>`);
}
}
};
// Click on a single picture
if(from.endsWith("pictures")) {
this._getPictureThumbURL(f.properties.id).then(displayThumb);
}
// Click on a grid cell
else if(from.endsWith("grid")) {
this._getThumbURL(coordinates).then(displayThumb);
}
// Click on a sequence
else {
this._getSequenceThumbURL(f.properties.id, new maplibregl.LngLat(...coordinates)).then(displayThumb);
}
}
/**
* Get picture thumbnail URL at given coordinates
*
* @param {LngLat} coordinates The map coordinates
* @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
* @private
* @memberof Panoramax.components.ui.Map#
*/
_getThumbURL(coordinates) {
return this._parent.api.getPicturesAroundCoordinates(coordinates[1], coordinates[0], 0.1, 1).then(res => {
const p = res?.features?.pop();
return p ? this._parent.api.findThumbnailInPictureFeature(p) : null;
});
}
/**
* Get picture thumbnail URL for a given sequence ID
*
* @param {string} seqId The sequence ID
* @param {LngLat} [coordinates] The map coordinates
* @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
* @private
* @memberof Panoramax.components.ui.Map#
*/
_getSequenceThumbURL(seqId, coordinates) {
if(coordinates) {
return this._parent.api.getPicturesAroundCoordinates(coordinates.lat, coordinates.lng, 1, 1, seqId)
.then(results => {
if(results?.features?.length > 0) {
return this._parent.api.findThumbnailInPictureFeature(results.features[0]);
}
else {
return this._parent.api.getPictureThumbnailURLForSequence(seqId);
}
});
}
else {
return this._parent.api.getPictureThumbnailURLForSequence(seqId);
}
}
/**
* Get picture thumbnail URL for a given picture ID.
* It handles a client-side cache based on raw API responses.
*
* @param {string} picId The picture ID
* @param {string} [seqId] The sequence ID (can speed up search if available)
* @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
* @memberof Panoramax.components.ui.Map#
* @private
*/
_getPictureThumbURL(picId, seqId) {
let res = null;
if(picId) {
if(this._picThumbUrl[picId] !== undefined) {
res = typeof this._picThumbUrl[picId] === "string" ? Promise.resolve(this._picThumbUrl[picId]) : this._picThumbUrl[picId];
}
else {
this._picThumbUrl[picId] = this._parent.api.getPictureThumbnailURL(picId, seqId).then(url => {
if(url) {
this._picThumbUrl[picId] = url;
return url;
}
else {
this._picThumbUrl[picId] = null;
return null;
}
})
.catch(() => {
this._picThumbUrl[picId] = null;
});
res = this._picThumbUrl[picId];
}
}
return res;
}
/**
* Create a ready-to-use picture marker
*
* @returns {maplibregl.Marker} The generated marker
* @private
* @memberof Panoramax.components.ui.Map#
*/
_getPictureMarker(selected = true) {
const img = document.createElement("img");
img.src = selected ? MarkerSelectedSVG : MarkerBaseSVG;
img.alt = "";
return new maplibregl.Marker({
element: img,
picId: null
})
// only picMarker could be draggable, don't for picMarkerPreview.
.setDraggable(selected && this._options.picMarkerDraggable)
;
}
/**
* Event handler for sequence hover
* @private
* @param {object} e Event data
* @memberof Panoramax.components.ui.Map#
*/
_onSequenceHover(e) {
e.preventDefault();
if(e.features.length > 0 && e.features[0].properties?.id) {
/**
* Event when a sequence on map is hovered (not selected)
*
* @event Panoramax.components.ui.Map#sequence-hover
* @type {maplibregl.util.evented.Event}
* @property {string} seqId The hovered sequence ID
*/
this.fire("sequence-hover", { seqId: e.features[0].properties.id });
}
}
/**
* Event handler for sequence click
* @private
* @param {object} e Event data
* @memberof Panoramax.components.ui.Map#
*/
_onSequenceClick(e) {
e.preventDefault();
// Skip if pictures navigation is set to "pic"
if(this._parent.psv?.getPicturesNavigation() === "pic") {
return;
}
if(e.features.length > 0 && e.features[0].properties?.id) {
/**
* Event when a sequence on map is clicked
* @event Panoramax.components.ui.Map#sequence-click
* @type {maplibregl.util.evented.Event}
* @property {string} seqId The clicked sequence ID
* @property {maplibregl.LngLat} coordinates The coordinates of user click
*/
this.fire("sequence-click", {
seqId: e.features[0].properties.id,
coordinates: e.lngLat
});
}
}
/**
* Event handler for picture click
* @private
* @param {object} e Event data
* @memberof Panoramax.components.ui.Map#
*/
_onPictureClick(e) {
e.preventDefault();
// Skip if pictures navigation is set to "pic"
if(this._parent.psv?.getPicturesNavigation() === "pic") {
return;
}
const f = e?.features?.length > 0 ? e.features[0] : null;
if(f?.properties?.id) {
// Look for a potential sequence ID
let seqId = null;
try {
if(f.properties.sequences) {
if(!Array.isArray(f.properties.sequences)) { f.properties.sequences = JSON.parse(f.properties.sequences); }
seqId = f.properties.sequences.pop();
}
}
catch(e) {
console.log("Sequence ID is not available in vector tiles for picture "+f.properties.id);
}
/**
* Event when a picture on map is clicked
*
* @event Panoramax.components.ui.Map#picture-click
* @type {maplibregl.util.evented.Event}
* @property {string} picId The clicked picture ID
* @property {string} seqId The clicked picture's sequence ID
* @property {object} feature The GeoJSON feature of the picture
*/
this.fire("picture-click", {
picId: f.properties.id,
seqId,
feature: f
});
}
}
/**
* Listen to map events.
* This is a binder to [`on`](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#on) and [`once`](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#once) MapLibre GL functions.
* @param {string} type The event type to listen for
* @param {function} listener The event handler
* @param {boolean} [options.once=false] Set to true to only listen to first event.
* @memberof Panoramax.components.ui.Map#
*/
addEventListener(type, listener, options = {}) {
if(options?.once) { this.once(type, listener); }
else { this.on(type, listener); }
}
}