@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
672 lines (605 loc) • 18.3 kB
JavaScript
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);
}