UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

1,002 lines (908 loc) 30.7 kB
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); } } }