@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
385 lines (331 loc) • 12 kB
JavaScript
import {
alterPSVState, MAP_FILTERS_JS2URL, alterMapState, alterViewerState, alterPhotoViewerState
} from "./InitParameters";
// List of supported parameters
const MANAGED_PARAMETERS = [
"speed", "nav", "focus", "pic", "xyz", "map",
"background", "users", "pic_score", "s", "xywh",
"annot", "seq",
].concat(Object.values(MAP_FILTERS_JS2URL));
// Events to listen on parent and PSV
const UPDATE_PARENT_EVENTS = ["focus-changed", "pictures-navigation-changed"];
const UPDATE_PSV_EVENTS = ["position-updated", "zoom-updated", "view-rotated", "picture-loaded", "transition-duration-changed", "annotation-focused", "annotations-unfocused"];
const UPDATE_MAP_EVENTS = ["moveend", "zoomend", "boxzoomend", "background-changed", "users-changed", "filters-changed"];
/**
* Updates the URL query part with various parent component information.
*
* Note that you may call `listenToChanges()` for this class to be effective once parent is ready-enough.
*
* @class Panoramax.utils.URLHandler
* @typicalname urlHandler
* @param {Panoramax.components.core.Basic} parent The parent component
* @fires Panoramax.utils.URLHandler#url-changed
*/
export default class URLHandler extends EventTarget {
constructor(parent) {
super();
this._parent = parent;
this._delay = null;
}
/**
* Start listening to URL & parent changes through events.
* This leads to parent & URL updates.
* @memberof Panoramax.utils.URLHandler#
*/
listenToChanges() {
window.addEventListener("popstate", this._onURLChange.bind(this), false);
UPDATE_PARENT_EVENTS.forEach(e => this._parent.addEventListener(e, this._onParentChange.bind(this)));
UPDATE_PSV_EVENTS.forEach(e => this._parent.psv.addEventListener(e, this._onParentChange.bind(this)));
if(this._parent.map) {
UPDATE_MAP_EVENTS.forEach(e => this._parent.map.on(e, this._onParentChange.bind(this)));
}
}
/**
* Call this function to stop listening to global events.
* @memberof Panoramax.utils.URLHandler#
*/
destroy() {
window.removeEventListener("popstate", this._onURLChange);
}
/**
* Compute next values to insert in URL
* @returns {object} Query parameters
* @memberof Panoramax.utils.URLHandler#
*/
nextURLParams() {
let hashParts = {};
if(typeof this._parent.psv.getTransitionDuration() == "number") {
hashParts.speed = this._parent.psv.getTransitionDuration();
}
if(![null, "any"].includes(this._parent.psv.getPicturesNavigation())) {
hashParts.nav = this._parent.psv.getPicturesNavigation();
}
if(this._parent.psv.getPictureId()) {
hashParts.pic = this._parent.psv.getPictureId();
}
const picMeta = this._parent.psv.getPictureMetadata();
if (picMeta) {
hashParts.xyz = this.currentPSVString();
if(picMeta.sequence?.id) { hashParts.seq = picMeta.sequence.id; }
}
const annots = this._parent.psv.getSelectedAnnotations();
if(annots?.length > 0) {
hashParts.annot = annots[0];
}
if(this._parent.map?.loaded) { // Use this test to avoid empty object checking in
hashParts.map = this.currentMapString();
hashParts.focus = "pic";
if(this._parent.isMapWide()) { hashParts.focus = "map"; }
if(this._parent.map?.hasTwoBackgrounds?.() && this._parent.map?.getBackground?.()) {
hashParts.background = this._parent.map.getBackground();
}
const vu = this._parent.map.getVisibleUsers();
if(vu.length > 1 || !vu.includes("geovisio")) {
hashParts.users = vu.join(",");
}
if(this._parent.map._mapFilters) {
for(let k in MAP_FILTERS_JS2URL) {
if(this._parent.map._mapFilters[k]) {
hashParts[MAP_FILTERS_JS2URL[k]] = this._parent.map._mapFilters[k];
}
}
if(hashParts.pic_score) {
const mapping = [null, "E", "D", "C", "B", "A"];
hashParts.pic_score = hashParts.pic_score.map(v => mapping[v]).join("");
}
}
}
return hashParts;
}
/**
* Compute next URL query string (based on `nextURLParams()`)
* @memberof Panoramax.utils.URLHandler#
* @return {string} The query string
*/
nextURLString() {
let hash = "";
Object.entries(this.nextURLParams())
.sort((a,b) => a[0].localeCompare(b[0]))
.forEach(entry => {
let [ hashName, value ] = entry;
let found = false;
const parts = hash.split("&").map(part => {
const key = part.split("=")[0];
if (key === hashName) {
found = true;
return `${key}=${value}`;
}
return part;
}).filter(a => a);
if (!found) {
parts.push(`${hashName}=${value}`);
}
hash = `${parts.join("&")}`;
});
return `?${hash}`.replace(/^\?+/, "?");
}
/**
* Transforms current URL query string into key->value object
* @param {boolean} [readFromHash=false] Switch to reading from hash URL part (for retro-compatibility)
* @return {object} Key-value read from current URL query
* @memberof Panoramax.utils.URLHandler#
*/
currentURLParams(readFromHash = false) {
// Get the current hash from location, stripped from its number sign
const hash = (readFromHash ? window.location.hash : window.location.search).replace(/^[?#]/, "");
// Split the parameter-styled hash into parts and find the value we need
let keyvals = {};
hash.split("&").map(
part => part.split("=")
)
.filter(part => part[0] !== undefined && part[0].length > 0 && MANAGED_PARAMETERS.includes(part[0]))
.forEach(part => {
keyvals[part[0]] = part[1];
});
// If hash is compressed
if(keyvals.s) {
const shortVals = Object.fromEntries(
decodeURIComponent(keyvals.s)
.split(";")
.map(kv => [kv[0], kv.substring(1)])
);
keyvals = {};
// Used letters: a b c d e f k m n p q s t u v
// NOTE: seq=* isn't used in shortlinks
// Focus
if(shortVals.f === "m") { keyvals.focus = "map"; }
else if(shortVals.f === "p") { keyvals.focus = "pic"; }
else if(shortVals.f === "t") { keyvals.focus = "meta"; }
// Speed
if(shortVals.s !== "") { keyvals.speed = parseFloat(shortVals.s) * 100; }
// Nav
if(shortVals.n === "a") { keyvals.nav = "any"; }
else if(shortVals.n === "s") { keyvals.nav = "seq"; }
if(["n", "p"].includes(shortVals.n)) { keyvals.nav = "pic"; }
// Pic
if(shortVals.p !== "") { keyvals.pic = shortVals.p; }
// Annotation
if(shortVals.a !== "") { keyvals.annot = shortVals.a; }
// XYZ
if(shortVals.c !== "") { keyvals.xyz = shortVals.c; }
// Map
if(shortVals.m !== "") { keyvals.map = shortVals.m; }
// Date
if(shortVals.d !== "") { keyvals.date_from = shortVals.d; }
if(shortVals.e !== "") { keyvals.date_to = shortVals.e; }
// Pic type
if(shortVals.t === "f") { keyvals.pic_type = "flat"; }
else if(shortVals.t === "e") { keyvals.pic_type = "equirectangular"; }
// Camera
if(shortVals.k !== "") { keyvals.camera = shortVals.k; }
// Theme
if(shortVals.v === "d") { keyvals.theme = "default"; }
else if(shortVals.v === "a") { keyvals.theme = "age"; }
else if(shortVals.v === "t") { keyvals.theme = "type"; }
else if(shortVals.v === "s") { keyvals.theme = "score"; }
// Background
if(shortVals.b === "s") { keyvals.background = "streets"; }
else if(shortVals.b === "a") { keyvals.background = "aerial"; }
// Users
if(shortVals.u !== "") { keyvals.users = shortVals.u; }
// Photoscore
if(shortVals.q !== "") { keyvals.pic_score = shortVals.q; }
}
return keyvals;
}
/**
* Get string representation of map position
* @returns {string} zoom/lat/lon or zoom/lat/lon/bearing/pitch
* @memberof Panoramax.utils.URLHandler#
*/
currentMapString() {
if(!this._parent.map?.loaded) { return ""; }
const center = this._parent.map.getCenter(),
zoom = Math.round(this._parent.map.getZoom() * 100) / 100,
// derived from equation: 512px * 2^z / 360 / 10^d < 0.5px
precision = Math.ceil((zoom * Math.LN2 + Math.log(512 / 360 / 0.5)) / Math.LN10),
m = Math.pow(10, precision),
lng = Math.round(center.lng * m) / m,
lat = Math.round(center.lat * m) / m,
bearing = this._parent.map.getBearing(),
pitch = this._parent.map.getPitch();
let hash = `${zoom}/${lat}/${lng}`;
if (bearing || pitch) hash += (`/${Math.round(bearing * 10) / 10}`);
if (pitch) hash += (`/${Math.round(pitch)}`);
return hash;
}
/**
* Get PSV view position as string
* @returns {string} x/y/z
* @memberof Panoramax.utils.URLHandler#
*/
currentPSVString() {
const xyz = this._parent.psv.getXYZ();
const x = xyz.x.toFixed(2),
y = xyz.y.toFixed(2),
z = Math.round(xyz.z || 0);
return `${x}/${y}/${z}`;
}
/**
* Updates map and PSV according to current hash values
* @private
* @memberof Panoramax.utils.URLHandler#
*/
_onURLChange() {
let vals = this.currentURLParams();
if(this._parent.getClassName() === "Viewer") { alterViewerState(this._parent, vals); }
else { alterPhotoViewerState(this._parent, vals); }
alterPSVState(this._parent.psv, vals);
if(this._parent.map) { alterMapState(this._parent.map, vals); }
}
/**
* Get short link URL (query replaced by Base64)
* @returns {str} The short link URL
* @memberof Panoramax.utils.URLHandler#
*/
nextShortLink(baseUrl) {
const url = new URL(baseUrl);
const hashParts = this.nextURLParams();
const shortVals = {
f: (hashParts.focus || "").substring(0, 1),
s: !isNaN(parseInt(hashParts.speed)) ? Math.floor(parseInt(hashParts.speed)/100) : undefined,
n: (hashParts.nav || "").substring(0, 1),
p: hashParts.pic,
c: hashParts.xyz,
m: hashParts.map,
d: hashParts.date_from,
e: hashParts.date_to,
t: (hashParts.pic_type || "").substring(0, 1),
k: hashParts.camera,
v: (hashParts.theme || "").substring(0, 1),
b: (hashParts.background || "").substring(0, 1),
u: hashParts.users,
q: hashParts.pic_score,
a: hashParts.annot,
};
const short = Object.entries(shortVals)
.filter(([,v]) => v != undefined && v != "")
.map(([k,v]) => `${k}${v}`)
.join(";");
url.search = `s=${short}`;
return url;
}
/**
* Returns a string containing only parameters out of URLHandler scope
* @param {URL} prevUrl The previously set URL
* @memberof Panoramax.utils.URLHandler#
*/
getUnmanagedParameters(prevUrl) {
return new URLSearchParams(
Array.from(prevUrl.searchParams)
.filter(([k]) => !MANAGED_PARAMETERS.includes(k))
).toString();
}
/**
* Changes the URL hash using current viewer parameters
* @private
* @memberof Panoramax.utils.URLHandler#
*/
_onParentChange() {
if(this._delay) {
clearTimeout(this._delay);
this._delay = null;
}
this._delay = setTimeout(() => {
const prevUrl = new URL(window.location.href);
const nextUrl = new URL(window.location.href);
const unmanaged = this.getUnmanagedParameters(prevUrl);
nextUrl.search = this._parent ? this.nextURLString() + (unmanaged.length > 0 ? "&"+unmanaged : ""): "";
// Clear out hash if older parameters appear
if(Object.keys(this.currentURLParams(true)).length > 0) { nextUrl.hash = ""; }
// Skip hash update if no changes
if(prevUrl.search == nextUrl.search) { return; }
const prevPic = this.currentURLParams().pic || "";
const nextPic = this._parent?.psv?.getPictureId?.() || "";
try {
// If different pic, add entry in browser history
if(prevPic != nextPic) {
window.history.pushState(window.history.state, null, nextUrl.href);
}
// If same pic, just update viewer params
else {
window.history.replaceState(window.history.state, null, nextUrl.href);
}
if(this._parent) {
/**
* URL changed event
* @event Panoramax.utils.URLHandler#url-changed
* @type {CustomEvent}
* @property {string} detail.url The new used URL
*/
const event = new CustomEvent("url-changed", { detail: {url: nextUrl.href}});
this.dispatchEvent(event);
}
} catch (SecurityError) {
// IE11 does not allow this if the page is within an iframe created
// with iframe.contentWindow.document.write(...).
// https://github.com/mapbox/mapbox-gl-js/issues/7410
}
}, 500);
}
}