@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
794 lines (711 loc) • 22.6 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 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",
}
}
};
export const MAP_THEMES = {
DEFAULT: "default",
AGE: "age",
TYPE: "type",
SCORE: "score",
};
// 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 comprehensive values from map filters form
* @param {Element} mapFiltersMenu The MapFilters DOM Element
* @param {Element} mapThemeSelect The Map Theme select DOM Element
* @param {boolean} hasQualityScore Set to true if vector tiles embed quality score data
* @return {object} The map filters as JSON
*/
export function mapFiltersFormValues(mapFiltersMenu, mapThemeSelect, hasQualityScore) {
const fMinDate = mapFiltersMenu?.shadowRoot.getElementById("pnx-filter-date-from");
const fMaxDate = mapFiltersMenu?.shadowRoot.getElementById("pnx-filter-date-end");
const fTypes = mapFiltersMenu?.shadowRoot.querySelectorAll("input[name='pnx-filter-type']");
let type = "";
for(let fTypeId = 0 ; fTypeId < fTypes.length; fTypeId++) {
const fType = fTypes[fTypeId];
if(fType.checked) {
type = fType.value;
break;
}
}
let qualityscore = [];
if(hasQualityScore) {
const fScore = mapFiltersMenu?.shadowRoot.getElementById("pnx-filter-qualityscore");
qualityscore = (fScore?.grade || "").split(",").map(v => parseInt(v)).filter(v => !isNaN(v));
if(qualityscore.length == 5) { qualityscore = []; }
}
return {
minDate: fMinDate?.value,
maxDate: fMaxDate?.value,
pic_type: type,
theme: mapThemeSelect?.value,
qualityscore,
};
}
/**
* Transforms map filters into MapLibre-like JSON filters
* @param {object} filters Filters returned by MapFilters widgets
* @param {boolean} [hasGridStats=false] Set to true if vector tiles offer grid statistics
* @returns {object} Set of variables { mapFilters, mapSeqFilters, mapPicFilters, reloadMapStyle }
*/
export function mapFiltersToLayersFilters(filters, hasGridStats = false) {
let mapFilters = {};
let mapSeqFilters = [];
let mapPicFilters = [];
let reloadMapStyle = false;
if(filters.minDate && filters.minDate !== "") {
mapFilters.minDate = filters.minDate;
mapSeqFilters.push([">=", ["get", "date"], filters.minDate]);
mapPicFilters.push([">=", ["get", "ts"], filters.minDate]);
}
if(filters.maxDate && filters.maxDate !== "") {
mapFilters.maxDate = filters.maxDate;
mapSeqFilters.push(["<=", ["get", "date"], filters.maxDate]);
// Get tomorrow date for pictures filtering
// (because ts is date+time, so comparing date only string would fail otherwise)
let d = new Date(filters.maxDate);
d.setDate(d.getDate() + 1);
d = d.toISOString().split("T")[0];
mapPicFilters.push(["<=", ["get", "ts"], d]);
}
if(filters.pic_type && filters.pic_type !== "") {
mapFilters.pic_type = filters.pic_type === "flat" ? "flat" : "equirectangular";
mapSeqFilters.push(["==", ["get", "type"], mapFilters.pic_type]);
mapPicFilters.push(["==", ["get", "type"], mapFilters.pic_type]);
}
if(hasGridStats) {
reloadMapStyle = true;
}
if(filters.camera && filters.camera !== "") {
mapFilters.camera = filters.camera;
// low/high model hack : to enable fuzzy filtering of camera make and model
const lowModel = filters.camera.toLowerCase().trim() + " ";
const highModel = filters.camera.toLowerCase().trim() + "zzzzzzzzzzzzzzzzzzzz";
const collator = ["collator", { "case-sensitive": false, "diacritic-sensitive": false } ];
mapSeqFilters.push([">=", ["get", "model"], lowModel, collator]);
mapSeqFilters.push(["<=", ["get", "model"], highModel, collator]);
mapPicFilters.push([">=", ["get", "model"], lowModel, collator]);
mapPicFilters.push(["<=", ["get", "model"], highModel, collator]);
}
if(filters.qualityscore && filters.qualityscore.length > 0) {
mapFilters.qualityscore = filters.qualityscore;
mapSeqFilters.push(["in", MAP_EXPR_QUALITYSCORE, ["literal", mapFilters.qualityscore]]);
mapPicFilters.push(["in", MAP_EXPR_QUALITYSCORE, ["literal", mapFilters.qualityscore]]);
}
if(filters.theme && Object.values(MAP_THEMES).includes(filters.theme)) {
mapFilters.theme = filters.theme;
reloadMapStyle = true;
}
if(mapSeqFilters.length == 0) { mapSeqFilters = null; }
else {
mapSeqFilters.unshift("all");
}
if(mapPicFilters.length == 0) { mapPicFilters = null; }
else {
mapPicFilters.unshift("all");
mapPicFilters = ["step", ["zoom"],
true,
TILES_PICTURES_ZOOM, mapPicFilters
];
}
return { mapFilters, mapSeqFilters, mapPicFilters, reloadMapStyle };
}
/**
* 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 if(parent.psv.getPicturesNavigation() === "any") { 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);
}