UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

672 lines (605 loc) 18.3 kB
import LoaderImg from "../img/marker.svg"; import { COLORS, QUALITYSCORE_RES_FLAT_VALUES, QUALITYSCORE_RES_360_VALUES, QUALITYSCORE_GPS_VALUES, QUALITYSCORE_POND_RES, QUALITYSCORE_POND_GPS } from "./utils"; import { autoDetectLocale } from "./i18n"; import { isNullId } from "./utils"; export const DEFAULT_TILES = "https://panoramax.openstreetmap.fr/pmtiles/basic.json"; export const RASTER_LAYER_ID = "pnx-aerial"; export const TILES_PICTURES_ZOOM = 15; export const TILES_PICTURES_SYMBOL_ZOOM = 18; export const VECTOR_STYLES = { PICTURES: { "paint": { "circle-radius": ["interpolate", ["linear"], ["zoom"], TILES_PICTURES_ZOOM, 4.5, TILES_PICTURES_SYMBOL_ZOOM, 6, 24, 12 ], "circle-opacity": ["interpolate", ["linear"], ["zoom"], TILES_PICTURES_ZOOM, 0, TILES_PICTURES_ZOOM+1, 1 ], "circle-stroke-color": "#ffffff", "circle-stroke-width": ["interpolate", ["linear"], ["zoom"], TILES_PICTURES_ZOOM+1, 0, TILES_PICTURES_ZOOM+2, 1, TILES_PICTURES_SYMBOL_ZOOM, 1.5, 24, 3 ], }, "layout": {} }, PICTURES_SYMBOLS: { "paint": { "icon-opacity": ["interpolate", ["linear"], ["zoom"], TILES_PICTURES_SYMBOL_ZOOM, 0, TILES_PICTURES_SYMBOL_ZOOM+1, 1], }, "layout": { "icon-image": ["case", ["==", ["get", "type"], "equirectangular"], "pnx-arrow-360", "pnx-arrow-flat"], "icon-size": ["interpolate", ["linear"], ["zoom"], TILES_PICTURES_SYMBOL_ZOOM, 0.5, 24, 1], "icon-rotate": ["to-number", ["get", "heading"]], "icon-allow-overlap": true, }, }, SEQUENCES: { "paint": { "line-width": ["interpolate", ["linear"], ["zoom"], 0, 0.5, 10, 2, 14, 4, 16, 5, 22, 3], }, "layout": { "line-cap": "square", } }, SEQUENCES_PLUS: { "paint": { "line-width": ["interpolate", ["linear"], ["zoom"], 0, 15, TILES_PICTURES_ZOOM+1, 30, TILES_PICTURES_ZOOM+2, 0], "line-opacity": 0, "line-color": "#ff0000", }, "layout": { "line-cap": "square", } } }; // See MapLibre docs for explanation of expressions magic: https://maplibre.org/maplibre-style-spec/expressions/ const MAP_EXPR_QUALITYSCORE_RES_360 = ["case", ["has", "h_pixel_density"], ["step", ["get", "h_pixel_density"], ...QUALITYSCORE_RES_360_VALUES], 1]; const MAP_EXPR_QUALITYSCORE_RES_FLAT = ["case", ["has", "h_pixel_density"], ["step", ["get", "h_pixel_density"], ...QUALITYSCORE_RES_FLAT_VALUES], 1]; const MAP_EXPR_QUALITYSCORE_RES = [ "case", ["==", ["get", "type"], "equirectangular"], MAP_EXPR_QUALITYSCORE_RES_360, MAP_EXPR_QUALITYSCORE_RES_FLAT ]; const MAP_EXPR_QUALITYSCORE_GPS = ["case", ["has", "gps_accuracy"], ["step", ["get", "gps_accuracy"], ...QUALITYSCORE_GPS_VALUES], 1]; // Note: score is also calculated in widgets/popup code export const MAP_EXPR_QUALITYSCORE = [ "round", ["+", ["*", MAP_EXPR_QUALITYSCORE_RES, QUALITYSCORE_POND_RES], ["*", MAP_EXPR_QUALITYSCORE_GPS, QUALITYSCORE_POND_GPS]] ]; /** * Get the GIF shown while thumbnail loads * @param {object} lang Translations * @returns The DOM element for this GIF * @private */ export function getThumbGif(lang) { const thumbGif = document.createElement("img"); thumbGif.src = LoaderImg; thumbGif.alt = lang.loading; thumbGif.title = lang.loading; thumbGif.classList.add("pnx-map-thumb", "pnx-map-thumb-loader"); return thumbGif; } export function isNullCoordinates(c) { return ( c === null || c === undefined || (Array.isArray(c) && ( (c.length === 2 && c[0] === 0 && c[1] === 0) || c.length < 2 )) ); } /** * Is given layer a label layer. * * This is useful for inserting new vector layer before labels in MapLibre. * @param {object} l The layer to check * @returns {boolean} True if it's a label layer * @private */ export function isLabelLayer(l) { return l.type === "symbol" && l?.layout?.["text-field"] !== undefined && (l.minzoom === undefined || l.minzoom < 15); } /** * Create all-in-one map style for MapLibre GL JS * * @param {CoreView} parent The parent view * @param {object} options Options from Map component * @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] Choose default map background to display (streets or aerial, if raster aerial background available). Defaults to street. * @param {object} [options.supplementaryStyle] Additional style properties (completing CoreView style and STAC API style) * @returns {object} The full MapLibre style * @private */ export function combineStyles(parent, options) { // Get basic vector styles const style = parent.api.getMapStyle(); // Complete styles style.layers = style.layers || []; style.layers = style.layers.concat(getMissingLayerStyles(style.sources, style.layers)); if(!style.metadata) { style.metadata = {}; } // Complementary style if(options.supplementaryStyle) { Object.assign(style.sources, options.supplementaryStyle.sources || {}); Object.assign(style.metadata, options.supplementaryStyle.metadata || {}); style.layers = style.layers.concat(options.supplementaryStyle.layers || []); } // Fix for empty layers causing sorting issues style.layers = style.layers.filter(l => l.id); // Aerial imagery background if(options.raster) { style.sources["pnx-aerial"] = options.raster; style.layers.push({ "id": RASTER_LAYER_ID, "type": "raster", "source": "pnx-aerial", "layout": { "visibility": options.background === "aerial" ? "visible" : "none", } }); } // Filter out general tiles if necessary if(!parent?.users?.includes("geovisio")) { style.layers.forEach(l => { if(l.source === "geovisio") { if(!l.layout) { l.layout = {}; } l.layout.visibility = "none"; } }); } // Order layers (base, geovisio, labels) style.layers.sort((a,b) => { if(isLabelLayer(a) && !isLabelLayer(b)) { return 1; } else if(!isLabelLayer(a) && isLabelLayer(b)) { return -1; } else { if(a.id.startsWith("geovisio") && !b.id.startsWith("geovisio")) { return 1; } else if(!a.id.startsWith("geovisio") && b.id.startsWith("geovisio")) { return -1; } else { if(a.id.endsWith("_pictures") && !b.id.endsWith("_pictures")) { return 1; } if(!a.id.endsWith("_pictures") && b.id.endsWith("_pictures")) { return -1; } else { return 0; } } } }); // TODO : remove override once available in default Panoramax style if(!style.metadata?.["panoramax:locales"]) { style.metadata["panoramax:locales"] = ["fr", "en", "de", "es", "ru", "pt", "zh", "hi", "latin"]; } // Override labels to use appropriate language if(style.metadata["panoramax:locales"]) { let prefLang = parent.lang || autoDetectLocale(style.metadata["panoramax:locales"], "latin"); if(prefLang.includes("-")) { prefLang = prefLang.split("-")[0]; } if(prefLang.includes("_")) { prefLang = prefLang.split("_")[0]; } style.layers.forEach(l => { if(isLabelLayer(l) && l.layout["text-field"].includes("name:latin")) { l.layout["text-field"] = [ "coalesce", ["get", `name:${prefLang}`], ["get", "name:latin"], ["get", "name"] ]; } }); } // Fix for capital cities const citiesLayer = style.layers.find(l => l.id == "place_label_city"); let capitalLayer = style.layers.find(l => l.id == "place_label_capital"); if(citiesLayer && !capitalLayer) { // Create capital layer from original city style citiesLayer.paint = { "text-color": "hsl(0, 0%, 0%)", "text-halo-blur": 0, "text-halo-color": "hsla(0, 0%, 100%, 1)", "text-halo-width": 3, }; citiesLayer.layout["text-letter-spacing"] = 0.1; capitalLayer = JSON.parse(JSON.stringify(citiesLayer)); capitalLayer.id = "place_label_capital"; capitalLayer.filter.push(["<=", "capital", 2]); // Edit original city to make it less import citiesLayer.filter.push([">", "capital", 2]); style.layers.push(capitalLayer); } // Fix for maxzoom on IGN tiles if(style?.sources?.plan_ign) { style.sources.plan_ign.maxzoom = 18; } return style; } /** * Identifies missing layers for a complete rendering of GeoVisio vector tiles. * This allows retro-compatibility with GeoVisio instances <= 2.5.0 * which didn't offer a MapLibre JSON style directly. * * @param {object} sources Pre-existing MapLibre style sources * @param {object[]} layers Pre-existing MapLibre style layers * @returns List of layers to add * @private */ export function getMissingLayerStyles(sources = {}, layers = []) { const newLayers = []; // GeoVisio API <= 2.5.0 : add sequences + pictures Object.keys(sources).filter(s => ( layers.find(l => l?.source === s) === undefined )).forEach(s => { if(s.startsWith("geovisio")) { // Basic sequences newLayers.push({ "id": `${s}_sequences`, "type": "line", "source": s, "source-layer": "sequences", "layout": { ...VECTOR_STYLES.SEQUENCES.layout }, "paint": { ...VECTOR_STYLES.SEQUENCES.paint, "line-color": COLORS.BASE, }, }); // Padded sequence (for easier click) newLayers.push({ "id": `${s}_sequences_plus`, "type": "line", "source": s, "source-layer": "sequences", "layout": { ...VECTOR_STYLES.SEQUENCES_PLUS.layout }, "paint": { ...VECTOR_STYLES.SEQUENCES_PLUS.paint }, }); // Pictures symbols newLayers.push({ "id": `${s}_pictures_symbols`, "type": "symbol", "source": s, "source-layer": "pictures", ...VECTOR_STYLES.PICTURES_SYMBOLS, }); // Pictures symbols newLayers.push({ "id": `${s}_pictures_symbols`, "type": "symbol", "source": s, "source-layer": "pictures", ...VECTOR_STYLES.PICTURES_SYMBOLS, }); // Pictures newLayers.push({ "id": `${s}_pictures`, "type": "circle", "source": s, "source-layer": "pictures", "layout": { ...VECTOR_STYLES.PICTURES.layout }, "paint": { ...VECTOR_STYLES.PICTURES.paint, "circle-color": COLORS.BASE, }, }); } }); // Add sequences_plus for easier click on map layers.filter(l => ( l?.id?.endsWith("_sequences") && layers.find(sl => sl?.id === l.id+"_plus") === undefined )).forEach(l => { newLayers.push({ "id": `${l.id}_plus`, "type": "line", "source": l.source, "source-layer": l["source-layer"], "layout": { ...VECTOR_STYLES.SEQUENCES_PLUS.layout }, "paint": { ...VECTOR_STYLES.SEQUENCES_PLUS.paint }, }); }); // Add pictures symbol for high-level zooms layers.filter(l => ( l?.id?.endsWith("_pictures") && layers.find(sl => sl?.id === l.id+"_symbols") === undefined )).forEach(l => { // Symbols newLayers.unshift({ "id": `${l.id}_symbols`, "type": "symbol", "source": l.source, "source-layer": "pictures", ...VECTOR_STYLES.PICTURES_SYMBOLS, }); // Patch style of pictures layer l.paint = Object.assign(l.paint || {}, VECTOR_STYLES.PICTURES.paint); l.layout = Object.assign(l.layout || {}, VECTOR_STYLES.PICTURES.layout); }); return newLayers; } /** * Get cleaned-up layer ID for a specific user. * @param {string} userId The user UUID (or "geovisio" for general layer) * @param {string} layerType The kind of layer (pictures, sequences...) * @returns {string} The cleaned-up layer ID for MapLibre * @private */ export function getUserLayerId(userId, layerType) { return `${getUserSourceId(userId)}_${layerType}`; } /** * Get cleaned-up source ID for a specific user. * @param {string} userId The user UUID (or "geovisio" for general layer) * @returns {string} The cleaned-up source ID for MapLibre * @private */ export function getUserSourceId(userId) { return userId === "geovisio" ? "geovisio" : "geovisio_"+userId; } /** * Switches used coef value in MapLibre style JSON expression * @param {*} expr The MapLibre style expression * @param {string} newCoefVal The new coef value to use * @returns {*} The switched expression * @private */ export function switchCoefValue(expr, newCoefVal) { if(Array.isArray(expr)) { return expr.map(v => switchCoefValue(v, newCoefVal)); } else if(typeof expr === "object" && expr !== null) { const newExpr = {}; for (const key in expr) { newExpr[key] = switchCoefValue(expr[key], newCoefVal); } return newExpr; } else if(typeof expr === "string" && expr.startsWith("coef")) { return newCoefVal; } return expr; } /** * Creates links between map and photo elements. * This enable interactions like click on map showing picture. * * @param {CoreView} parent The view containing both Photo and Map elements * @private */ export function linkMapAndPhoto(parent) { // Switched picture const onPicLoad = e => { if(isNullId(e.detail.picId)) { parent.map.displayPictureMarker(); if(parent?.isMapWide?.()) { parent?.mini?.setAttribute("collapsed", ""); } } else { parent.map.displayPictureMarker( e.detail.lon, e.detail.lat, parent.psv.getXY().x, e.detail.first && !isNullCoordinates(parent._initParams?.getMapPostInit()?.center), // Skip centering if precise coordinates are set from URL e.detail.picId ); if(parent?.isMapWide?.()) { parent?.mini?.removeAttribute("collapsed"); } } }; parent.psv.addEventListener("picture-loading", onPicLoad); parent.psv.addEventListener("picture-loaded", onPicLoad); // Picture view rotated parent.psv.addEventListener("view-rotated", () => { let x = parent.psv.getPosition().yaw * (180 / Math.PI); x += parent.psv.getPictureOriginalHeading(); parent.map._picMarker.setRotation(x); }); // Picture preview parent.psv.addEventListener("picture-preview-started", e => { // Show marker corresponding to selection parent.map._picMarkerPreview .setLngLat(e.detail.coordinates) .setRotation(e.detail.direction || 0) .addTo(parent.map); }); parent.psv.addEventListener("picture-preview-stopped", () => { parent.map._picMarkerPreview.remove(); }); parent.psv.addEventListener("picture-loaded", e => { if (parent.isWidthSmall() && parent._picPopup && e.detail.picId == parent._picPopup._picId) { parent._picPopup.remove(); } }); // Picture click parent.map.on("picture-click", e => { parent.select(e.seqId, e.picId); if(!parent.psv._myVTour.state.currentNode && parent?._setFocus) { parent._setFocus("pic"); } }); // Sequence click parent.map.on("sequence-click", e => { parent.api.getPicturesAroundCoordinates( e.coordinates.lat, e.coordinates.lng, 1, 1, e.seqId ).then(results => { if(results?.features?.length > 0) { parent.select(results.features[0]?.collection, results.features[0].id); if(!parent.psv.getPictureMetadata() && parent?._setFocus) { parent._setFocus("pic"); } } }); }); // Focus changes if(parent._setFocus) { // PSV double click parent.psv.addEventListener("dblclick", () => { if(parent.isMapWide()) { parent._setFocus("pic"); } }); // Map double-click: unselect if focused, toggle focus if unfocused parent.map.on("dblclick", () => { if(!parent.isMapWide()) { parent._setFocus("map"); } else { parent.select(); } }); } } /** * Adds events related to keyboard * @private */ export function initMapKeyboardHandler(parent) { parent.map.keyboard.keydown = function(e) { if (e.altKey || e.ctrlKey || e.metaKey) return; // Custom keys switch(e.key) { case "*": case "5": parent.moveCenter(); return; case "PageUp": case "9": parent.psv.goToNextPicture(); return; case "PageDown": case "3": parent.psv.goToPrevPicture(); return; case "Home": case "7": e.stopPropagation(); parent._toggleFocus(); return; case "End": case "1": parent.mini.toggleAttribute("collapsed"); return; case " ": case "0": parent.psv.toggleSequencePlaying(); return; } let zoomDir = 0; let bearingDir = 0; let pitchDir = 0; let xDir = 0; let yDir = 0; switch (e.keyCode) { case 61: case 107: case 171: case 187: zoomDir = 1; break; case 189: case 109: case 173: zoomDir = -1; break; case 37: case 100: if (e.shiftKey) { bearingDir = -1; } else { e.preventDefault(); xDir = -1; } break; case 39: case 102: if (e.shiftKey) { bearingDir = 1; } else { e.preventDefault(); xDir = 1; } break; case 38: case 104: if (e.shiftKey) { pitchDir = 1; } else { e.preventDefault(); yDir = -1; } break; case 40: case 98: if (e.shiftKey) { pitchDir = -1; } else { e.preventDefault(); yDir = 1; } break; default: return; } if (this._rotationDisabled) { bearingDir = 0; pitchDir = 0; } return { cameraAnimation: (map) => { const tr = this._tr; map.easeTo({ duration: 300, easeId: "keyboardHandler", easing: t => t * (2-t), zoom: zoomDir ? Math.round(tr.zoom) + zoomDir * (e.shiftKey ? 2 : 1) : tr.zoom, bearing: tr.bearing + bearingDir * this._bearingStep, pitch: tr.pitch + pitchDir * this._pitchStep, offset: [-xDir * this._panStep, -yDir * this._panStep], center: tr.center }, {originalEvent: e}); } }; }.bind(parent.map.keyboard); } const MAP_PARAMS_STORAGE = "pnx-map-parameters"; /** * Reads map parameters from localStorage * @private */ export function getMapParamsFromLocalStorage() { const params = localStorage.getItem(MAP_PARAMS_STORAGE); if(!params) { return {}; } try { return JSON.parse(params); } catch(e) { console.warn("Can't read map parameters stored in localStorage", e); return {}; } } /** * Save map parameters into localStorage. * @private */ export function saveMapParamsToLocalStorage(map) { // Save map state in localStorage const save = () => localStorage.setItem(MAP_PARAMS_STORAGE, JSON.stringify({ center: Object.fromEntries(Object.entries(map.getCenter()).map(([k,v]) => ([k,v.toFixed(7)]))), zoom: map.getZoom().toFixed(1), background: map.getBackground(), theme: map._mapFilters.theme })); // Add events to know when to rewrite info map.on("background-changed", save); map.on("filters-changed", save); map.on("moveend", save); map.on("zoomend", save); map.on("dragend", save); map.on("boxzoomend", save); }