@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
441 lines (395 loc) • 13.7 kB
JavaScript
/* eslint-disable no-unused-vars */
import "./Editor.css";
import Basic from "./Basic";
import Map from "../ui/Map";
import Photo from "../ui/Photo";
import { apiFeatureToPSVNode } from "../../utils/picture";
import { linkMapAndPhoto } from "../../utils/map";
import { VECTOR_STYLES } from "../../utils/map";
import { SYSTEM as PSSystem } from "@photo-sphere-viewer/core";
import { createWebComp } from "../../utils/widgets";
const LAYER_HEADING_ID = "sequence-headings";
/**
* Editor allows to focus on a single sequence, and preview what you edits would look like.
* It shows both picture and map.
*
* Make sure to set width/height through CSS for proper display.
*
* This component has a [CorneredGrid](#Panoramax.components.layout.CorneredGrid) layout, you can use directly any slot element to pass custom widgets.
* @class Panoramax.components.core.Editor
* @element pnx-editor
* @extends Panoramax.components.core.Basic
* @fires Panoramax.components.core.Basic#select
* @fires Panoramax.components.core.Basic#ready
* @fires Panoramax.components.core.Basic#broken
* @property {Panoramax.components.ui.Loader} loader The loader screen
* @property {Panoramax.utils.API} api The API manager
* @property {Panoramax.components.ui.Map} map The MapLibre GL map itself
* @property {Panoramax.components.ui.Photo} psv The Photo Sphere Viewer component itself
* @property {Panoramax.components.layout.CorneredGrid} grid The grid layout manager
* @slot `top-left` The top-left corner
* @slot `top` The top middle corner
* @slot `top-right` The top-right corner
* @slot `bottom-left` The bottom-left corner
* @slot `bottom` The bottom middle corner
* @slot `bottom-right` The bottom-right corner
* @example
* ```html
* <!-- Basic example -->
* <pnx-editor
* endpoint="https://panoramax.openstreetmap.fr/"
* style="width: 300px; height: 250px"
* />
*
* <!-- With slotted widgets -->
* <pnx-editor
* endpoint="https://panoramax.openstreetmap.fr/"
* style="width: 300px; height: 250px"
* >
* <p slot="top-right">My custom text</p>
* </pnx-editor>
* ```
*/
export default class Editor extends Basic {
/**
* Component properties. All of [Basic properties](#Panoramax.components.core.Basic+properties) are available as well.
* @memberof Panoramax.components.core.Editor#
* @mixes Panoramax.components.core.Basic#properties
* @type {Object}
* @property {string} endpoint URL to API to use (must be a [STAC API](https://github.com/radiantearth/stac-api-spec/blob/main/overview.md))
* @property {string} [picture] The picture ID to display
* @property {string} [sequence] The sequence ID of the picture displayed
* @property {object} [fetch-options] Set custom options for fetch calls made against API ([same syntax as fetch options parameter](https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters))
* @property {string|object} [map-style] The map's MapLibre style. This can be an a JSON object conforming to the schema described in the [MapLibre Style Specification](https://maplibre.org/maplibre-style-spec/), or a URL string pointing to one. Defaults to OSM vector tiles.
* @property {string} [lang] To override language used for labels. Defaults to using user's preferred languages.
* @property {object} [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).
* @property {string} [background=streets] Choose default map background to display (streets or aerial, if raster aerial background available). Defaults to street.
*/
static properties = {
raster: {converter: Basic.GetJSONConverter()},
background: {type: String},
...Basic.properties
};
constructor() {
super();
this.raster = null;
this.background = "streets";
this.users = []; // Avoid default map data showing up
// Create sub-containers
this._psvContainer = document.createElement("div");
this._psvContainer.setAttribute("slot", "bg");
this._mapContainer = document.createElement("div");
this._mapContainer.setAttribute("slot", "bg");
this.grid = createWebComp("pnx-cornered-grid");
this.grid.appendChild(this._psvContainer);
this.grid.appendChild(this._mapContainer);
this.onceAPIReady().then(() => {
this.loader.setAttribute("value", 30);
// Check sequence ID is set
if(!this.sequence) { this.loader.dismiss({}, "No sequence is selected"); }
// Events
this.addEventListener("select", this._onSelect.bind(this));
this._initPSV();
this._initMap();
});
}
/** @private */
disconnectedCallback() {
super.disconnectedCallback();
this.map?.destroy();
this.psv?.destroy();
}
getClassName() {
return "Editor";
}
onceReady() {
if(this.map && this.psv && this.map.loaded?.()) {
return Promise.resolve();
}
else {
return new Promise(resolve => setTimeout(resolve, 100)).then(this.onceReady.bind(this));
}
}
connectedCallback() {
if(Array.isArray(this.users) && this.users.length > 0) {
console.warn("Parameters users can't be changed in Editor, only selected sequence can be visible");
this.users = [];
}
super.connectedCallback();
this._moveChildToGrid();
window.addEventListener("DOMContentLoaded", () => {
this._moveChildToGrid();
}, { once: true });
}
attributeChangedCallback(name, old, value) {
if(name === "users" && Array.isArray(value) && value.length > 0) {
console.warn("Parameters users can't be changed in Editor, only selected sequence can be visible");
}
else {
super.attributeChangedCallback(name, old, value);
}
}
/** @private */
render() {
return [this.loader, this.grid];
}
getSubComponentsNames() {
return super.getSubComponentsNames().concat(["map", "psv", "grid"]);
}
/** @private */
_initPSV() {
try {
this.psv = new Photo(this, this._psvContainer);
this.psv._myVTour.datasource.nodeResolver = this._getNode.bind(this);
}
catch(e) {
let err = !PSSystem.isWebGLSupported ? this._t.pnx.error_webgl : this._t.pnx.error_psv;
this.loader.dismiss(e, err);
}
}
/** @private */
_initMap() {
try {
this.map = new Map(this, this._mapContainer, {
raster: this.raster,
background: this.background,
supplementaryStyle: this._createMapStyle(),
zoom: 15, // Hack to avoid _initMapPosition call
picMarkerDraggable: true,
});
linkMapAndPhoto(this);
this.loader.setAttribute("value", 50);
this._loadSequence();
this.map.once("load", () => {
if(this.map.hasTwoBackgrounds()) { this._addMapBackgroundWidget(); }
this._bindPicturesEvents();
});
// Override picMarker setRotation for heading preview
const oldRot = this.map._picMarker.setRotation.bind(this.map._picMarker);
this.map._picMarker.setRotation = h => {
h = this._lastRelHeading === undefined ? h : h + this._lastRelHeading - this.psv.getPictureRelativeHeading();
return oldRot(h);
};
}
catch(e) {
this.loader.dismiss(e, this._t.pnx.error_psv);
}
}
/**
* Create style for GeoJSON sequence data.
* @private
*/
_createMapStyle() {
return {
sources: {
geovisio_editor_sequences: {
type: "geojson",
data: {"type": "FeatureCollection", "features": [] }
}
},
layers: [
{
"id": "geovisio_editor_sequences",
"type": "line",
"source": "geovisio_editor_sequences",
"layout": {
...VECTOR_STYLES.SEQUENCES.layout
},
"paint": {
...VECTOR_STYLES.SEQUENCES.paint
},
},
{
"id": "geovisio_editor_pictures",
"type": "circle",
"source": "geovisio_editor_sequences",
"layout": {
...VECTOR_STYLES.PICTURES.layout
},
"paint": {
...VECTOR_STYLES.PICTURES.paint
},
}
]
};
}
/**
* Creates events handlers on pictures layer
* @private
*/
_bindPicturesEvents() {
// Pictures events
this.map.on("mousemove", "geovisio_editor_pictures", () => {
this.map.getCanvas().style.cursor = "pointer";
});
this.map.on("mouseleave", "geovisio_editor_pictures", () => {
this.map.getCanvas().style.cursor = "";
});
this.map.on("click", "geovisio_editor_pictures", this.map._onPictureClick.bind(this.map));
}
/**
* Displays currently selected sequence on map
* @private
*/
_loadSequence() {
this.loader.setAttribute("value", 60);
return this.api.getSequenceItems(this.sequence).then(seq => {
this.loader.setAttribute("value", 80);
// Hide loader after source load
this.map.once("sourcedata", () => {
this.map.setPaintProperty("geovisio_editor_sequences", "line-color", this.map._getLayerColorStyle("sequences"));
this.map.setPaintProperty("geovisio_editor_pictures", "circle-color", this.map._getLayerColorStyle("pictures"));
this.map.setLayoutProperty("geovisio_editor_sequences", "visibility", "visible");
this.map.setLayoutProperty("geovisio_editor_pictures", "visibility", "visible");
this.map.once("styledata", () => this.loader.dismiss());
});
// Create data source
this._sequenceData = seq.features;
this.map.getSource("geovisio_editor_sequences").setData({
"type": "FeatureCollection",
"features": [
{
"id": this.sequence,
"type": "Feature",
"properties": {
"id": this.sequence,
},
"geometry":
{
"type": "LineString",
"coordinates": seq.features.map(p => p.geometry.coordinates)
}
},
...seq.features.map(f => {
f.properties.id = f.id;
f.properties.sequences = [this.sequence];
return f;
})
]
});
// Select picture if any
if(this.picture) {
const pic = seq.features.find(p => p.id === this.picture);
if(pic) {
this.select(this.sequence, this.picture, true);
this.map.jumpTo({ center: pic.geometry.coordinates, zoom: 18 });
}
else {
console.log("Picture with ID", pic, "was not found");
}
}
// Show area of sequence otherwise
else {
const bbox = [
...seq.features[0].geometry.coordinates,
...seq.features[0].geometry.coordinates
];
for(let i=1; i < seq.features.length; i++) {
const c = seq.features[i].geometry.coordinates;
if(c[0] < bbox[0]) { bbox[0] = c[0]; }
if(c[1] < bbox[1]) { bbox[1] = c[1]; }
if(c[0] > bbox[2]) { bbox[2] = c[0]; }
if(c[1] > bbox[3]) { bbox[3] = c[1]; }
}
this.map.fitBounds(bbox, {animate: false});
}
}).catch(e => this.loader.dismiss(e, this._t.pnx.error_api));
}
/**
* Get the PSV node for wanted picture.
*
* @param {string} picId The picture ID
* @returns The PSV node
* @private
*/
_getNode(picId) {
const f = this._sequenceData.find(f => f.properties.id === picId);
const n = f ? apiFeatureToPSVNode(f, this._t, this._isInternetFast, undefined, u => this.api.cleanResourceURL(u)) : null;
if(n) { delete n.links; }
return n;
}
/**
* Creates the widget to switch between aerial and streets imagery
* @private
*/
_addMapBackgroundWidget() {
// Container
const pnlLayers = createWebComp("pnx-map-background", {_parent: this, size: "sm", slot: "bottom-left"});
this.grid.appendChild(pnlLayers);
}
/** @private */
_moveChildToGrid() {
const slotContent = Array.from(this.querySelectorAll("[slot]"));
slotContent.forEach(n => {
// Add parent + translation for our components
if(n.tagName?.toLowerCase().startsWith("pnx-")) {
n._parent = this;
n._t = this._t;
}
this.grid.appendChild(n);
});
}
/**
* Preview on map how the new relative heading would reflect on all pictures.
* This doesn't change anything on API-side, it's just a preview.
* @memberof Panoramax.components.core.Editor#
* @param {number} [relHeading] The new relative heading compared to sequence path. In degrees, between -180 and 180 (0 = front, -90 = left, 90 = right). Set to null to remove preview.
*/
previewSequenceHeadingChange(relHeading) {
const layerExists = this.map.getLayer(LAYER_HEADING_ID) !== undefined;
this.map._picMarkerPreview.remove();
// If no value set, remove layer
if(relHeading === undefined) {
delete this._lastRelHeading;
if(layerExists) {
this.map.setLayoutProperty(LAYER_HEADING_ID, "visibility", "none");
}
// Update selected picture marker
if(this.picture) {
this.map._picMarker.setRotation(this.psv.getXY().x);
}
return;
}
this._lastRelHeading = relHeading;
// Create preview layer
if(!layerExists) {
this.map.addLayer({
"id": LAYER_HEADING_ID,
"type": "symbol",
"source": "geovisio_editor_sequences",
"layout": {
"icon-image": "pnx-marker",
"icon-overlap": "always",
"icon-size": 0.8,
},
});
}
// Change heading
const currentRelHeading = - this.psv.getPictureRelativeHeading();
this.map.setLayoutProperty(LAYER_HEADING_ID, "visibility", "visible");
this.map.setLayoutProperty(
LAYER_HEADING_ID,
"icon-rotate",
["+", ["get", "view:azimuth"], currentRelHeading, relHeading ]
);
// Skip selected picture and linestring geom
const filters = [["==", ["geometry-type"], "Point"]];
if(this.picture) { filters.push(["!=", ["get", "id"], this.picture]); }
this.map.setFilter(LAYER_HEADING_ID, ["all", ...filters]);
// Update selected picture marker
if(this.picture) {
this.map._picMarker.setRotation(this.psv.getXY().x);
}
}
/**
* Event handler for picture loading
* @private
*/
_onSelect() {
// Update preview of heading change
if(this._lastRelHeading !== undefined) {
this.previewSequenceHeadingChange(this._lastRelHeading);
}
}
}
customElements.define("pnx-editor", Editor);