push project vue TS + Lib
1
.eslintignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
src/lib/*
|
||||||
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
12
.idea/geovisio-website.iml
generated
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
6
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/geovisio-website.iml" filepath="$PROJECT_DIR$/.idea/geovisio-website.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# panoramax-website
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||||
|
|
||||||
|
1. Disable the built-in TypeScript Extension
|
||||||
|
1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||||
|
2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||||
|
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run workflows:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy with Github Pages
|
||||||
|
|
||||||
|
The deploy is on the `gh-pages` branch.
|
||||||
|
1 - You must build a static project :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vite build
|
||||||
|
```
|
||||||
|
|
||||||
|
2 - Add and Commit :
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ga .
|
||||||
|
gc -m 'my commit'
|
||||||
|
```
|
||||||
|
|
||||||
|
3 - Push on the specific branch
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git subtree push --prefix dist origin gh-pages
|
||||||
|
```
|
||||||
27
index.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Geovisio, la cartographie du monde</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Panoramax fédère les initiatives (des collectivités, des contributeurs OSM, de l’IGN...) pour favoriser l'émergence d'un géocommun de bases de vues immersives."
|
||||||
|
/>
|
||||||
|
<meta name="twitter:title" content="Panoramax" />
|
||||||
|
<meta name="og:title" content="Panoramax" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Panoramax fédère les initiatives (des collectivités, des contributeurs OSM, de l’IGN...) pour favoriser l'émergence d'un géocommun de bases de vues immersives."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="og:description"
|
||||||
|
content="Panoramax fédère les initiatives (des collectivités, des contributeurs OSM, de l’IGN...) pour favoriser l'émergence d'un géocommun de bases de vues immersives."
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
package.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "geovisio-website",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"start": "vite",
|
||||||
|
"build": "run-p type-check build-only",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest --environment jsdom --root src/",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"deploy": "yarn build && cd ./dist && cp index.html 404.html",
|
||||||
|
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||||
|
"lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src",
|
||||||
|
"format": "prettier . --write"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"maplibre-gl": "^2.4.0",
|
||||||
|
"photo-sphere-viewer": "^4.8.1",
|
||||||
|
"vue": "^3.2.45",
|
||||||
|
"vue-eslint-parser": "^9.1.0",
|
||||||
|
"vue-i18n": "9",
|
||||||
|
"vue-router": "^4.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@rushstack/eslint-patch": "^1.1.4",
|
||||||
|
"@types/jsdom": "^20.0.1",
|
||||||
|
"@types/node": "^18.11.9",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.46.0",
|
||||||
|
"@typescript-eslint/parser": "^5.4.0",
|
||||||
|
"@vitejs/plugin-vue": "^3.2.0",
|
||||||
|
"@vue/cli-plugin-typescript": "~5.0.0",
|
||||||
|
"@vue/eslint-config-prettier": "^7.0.0",
|
||||||
|
"@vue/eslint-config-typescript": "^11.0.0",
|
||||||
|
"@vue/test-utils": "^2.2.4",
|
||||||
|
"@vue/tsconfig": "^0.1.3",
|
||||||
|
"eslint": "^8.29.0",
|
||||||
|
"eslint-config-prettier": "^8.5.0",
|
||||||
|
"eslint-plugin-vue": "^9.8.0",
|
||||||
|
"jsdom": "^20.0.3",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"prettier": "2.8.1",
|
||||||
|
"typescript": "~4.7.4",
|
||||||
|
"vite": "^3.2.4",
|
||||||
|
"vite-plugin-eslint": "^1.8.1",
|
||||||
|
"vitest": "^0.25.3",
|
||||||
|
"vue-tsc": "^1.0.9"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"@vue/typescript/recommended"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"preset": "@vue/cli-plugin-unit-jest/presets/typescript-and-babel"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/.DS_Store
vendored
Normal file
11
src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
src/assets/.DS_Store
vendored
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-Black.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-BlackItalic.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-Bold.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-BoldItalic.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-ExtraLight.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-Italic.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-Light.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-LightItalic.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-Regular.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-SemiBold.ttf
Normal file
BIN
src/assets/fonts/SourceSansPro/SourceSansPro-SemiBoldItalic.ttf
Normal file
BIN
src/assets/images/.DS_Store
vendored
Normal file
21
src/assets/main.css
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/components/.DS_Store
vendored
Normal file
166
src/lib/API.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* API contains various utility functions to communicate with the backend
|
||||||
|
*
|
||||||
|
* @param {string} endpoint The <a href="https://github.com/radiantearth/stac-api-spec/tree/master/item-search">STAC Item Search</a> API endpoint
|
||||||
|
* @param {string} [picturesTiles] The pictures vector tiles endpoint, defaults to /api/map/{z}/{x}/{y}.mvt relatively to endpoint parameter
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class API {
|
||||||
|
constructor(endpoint, picturesTiles) {
|
||||||
|
// Parse local endpoints
|
||||||
|
if(endpoint.startsWith("/")) {
|
||||||
|
endpoint = window.location.href.split("/").slice(0, 3).join("/") + endpoint;
|
||||||
|
}
|
||||||
|
//endpoint = 'https://geovisio.osc-fr1.scalingo.io';
|
||||||
|
|
||||||
|
// Check endpoint
|
||||||
|
if(!API.isValidHttpUrl(endpoint)) {
|
||||||
|
throw new Error("endpoint parameter is not a valid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
this._endpoint = endpoint;
|
||||||
|
this._picturesTiles = picturesTiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full URL for listing pictures around a specific location
|
||||||
|
*
|
||||||
|
* @param {number} lat Latitude
|
||||||
|
* @param {number} lon Longitude
|
||||||
|
* @returns {string} The corresponding URL
|
||||||
|
*/
|
||||||
|
getPicturesAroundCoordinatesUrl(lat, lon) {
|
||||||
|
if(isNaN(parseFloat(lat)) || isNaN(parseFloat(lon))) {
|
||||||
|
throw new Error("lat and lon parameters should be valid numbers");
|
||||||
|
}
|
||||||
|
|
||||||
|
const factor = 0.0005;
|
||||||
|
const bbox = [ lon - factor, lat - factor, lon + factor, lat + factor ].map(d => d.toFixed(4)).join(",");
|
||||||
|
return `${this._endpoint}?bbox=[${bbox}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full URL for retrieving a specific picture metadata
|
||||||
|
*
|
||||||
|
* @param {string} picId The picture unique identifier
|
||||||
|
* @returns {string} The corresponding URL
|
||||||
|
*/
|
||||||
|
getPictureMetadataUrl(picId) {
|
||||||
|
API.isPictureIdValid(picId);
|
||||||
|
return `${this._endpoint}?ids=["${picId}"]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get full URL for pictures vector tiles
|
||||||
|
*
|
||||||
|
* @returns {string} The URL
|
||||||
|
* @fires Error If URL can't be determined
|
||||||
|
*/
|
||||||
|
getPicturesTilesUrl() {
|
||||||
|
// Explicitly defined URL
|
||||||
|
if(this._picturesTiles) {
|
||||||
|
return this._picturesTiles;
|
||||||
|
}
|
||||||
|
// GeoVisio endpoint
|
||||||
|
else if(this.isGeoVisioEndpoint()) {
|
||||||
|
return this._endpoint.replace("/api/search", "/api/map/{z}/{x}/{y}.mvt");
|
||||||
|
}
|
||||||
|
// Unknown endpoint, throw an error
|
||||||
|
else {
|
||||||
|
throw new Error("Pictures vector tiles URL is unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a picture ID for a given sequence
|
||||||
|
*
|
||||||
|
* @param {string} seqId The sequence ID
|
||||||
|
* @returns {Promise} Promise resolving on a single picture metadata, or null if no image found
|
||||||
|
*/
|
||||||
|
getPictureThumbnailForSequence(seqId) {
|
||||||
|
const url = `${this._endpoint}?limit=1&collections=["${seqId}"]`;
|
||||||
|
|
||||||
|
return fetch(url)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(res => res.features.length > 0 ? res.features[0] : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get thumbnail URL for a specific picture
|
||||||
|
*
|
||||||
|
* @param {string} picId The picture unique identifier
|
||||||
|
* @returns {Promise} The corresponding URL on resolve, or undefined if no thumbnail could be found
|
||||||
|
*/
|
||||||
|
getPictureThumbnailUrl(picId) {
|
||||||
|
if(this.isGeoVisioEndpoint()) {
|
||||||
|
return Promise.resolve(`${this.getGeoVisioRoot()}/api/pictures/${picId}/thumb.jpg`);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return fetch(this.getPictureMetadataUrl(picId))
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(res => Object.values(res?.features.pop()?.assets || {})
|
||||||
|
.find(a => a.roles.includes("thumbnail") && a.type.startsWith("image/"))
|
||||||
|
?.href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if given endpoint is a GeoVisio server or not
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if is a GeoVisio instance
|
||||||
|
*/
|
||||||
|
isGeoVisioEndpoint() {
|
||||||
|
return this._endpoint.includes("/api/search");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the GeoVisio root API endpoint (instead of search)
|
||||||
|
* This is mainly an internal utility
|
||||||
|
*
|
||||||
|
* @returns {string} The root of GeoVisio endpoint
|
||||||
|
* @fires Error If it's not a Geovisio API
|
||||||
|
*/
|
||||||
|
getGeoVisioRoot() {
|
||||||
|
if(this.isGeoVisioEndpoint()) {
|
||||||
|
return this._endpoint.replace(/\/api\/search.*$/, "");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error("Can't get root endpoint on a third-party STAC API");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks URL string validity
|
||||||
|
*
|
||||||
|
* @param {string} str The URL to check
|
||||||
|
* @returns {boolean} True if valid
|
||||||
|
*/
|
||||||
|
static isValidHttpUrl(str) {
|
||||||
|
let url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(str);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks picture ID validity
|
||||||
|
*
|
||||||
|
* @param {string} picId The picture unique identifier
|
||||||
|
* @returns {boolean} True if valid
|
||||||
|
* @throws {Error} If not valid
|
||||||
|
*/
|
||||||
|
static isPictureIdValid(picId) {
|
||||||
|
if(!picId || typeof picId !== "string" || picId.length === 0) {
|
||||||
|
throw new Error("picId should be a valid picture unique identifier");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default API;
|
||||||
401
src/lib/Map.js
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import "./css/Map.css";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import MiniComponentButtons from "./MiniComponentButtons";
|
||||||
|
import MarkerSVG from "./img/marker.svg";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Map showing photo location
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export default class Map {
|
||||||
|
/**
|
||||||
|
* @param {external:photo-sphere-viewer.Viewer} psv The viewer
|
||||||
|
* @param {object} [options] Optional settings (can be any of [MapLibre GL settings](https://maplibre.org/maplibre-gl-js-docs/api/map/#map-parameters))
|
||||||
|
*/
|
||||||
|
constructor(psv, options = {}) {
|
||||||
|
this.psv = psv;
|
||||||
|
this.container = document.createElement("div");
|
||||||
|
this.container.classList.add("psv-map", "gvs-map-small");
|
||||||
|
this.psv.parent.appendChild(this.container);
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
this._mapContainer = document.createElement("div");
|
||||||
|
this._map = new maplibregl.Map({
|
||||||
|
container: this._mapContainer,
|
||||||
|
style: "https://tile-vect.openstreetmap.fr/styles/basic/style.json",
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: 0,
|
||||||
|
hash: "map",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
this._map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||||
|
|
||||||
|
// Widgets and markers
|
||||||
|
this._miniButtons = new MiniComponentButtons(this);
|
||||||
|
this.container.appendChild(this._mapContainer);
|
||||||
|
this._picMarker = this._getPictureMarker();
|
||||||
|
|
||||||
|
this._map.on("load", () => {
|
||||||
|
this._createPicturesTilesLayer();
|
||||||
|
this._listenToViewerEvents();
|
||||||
|
this._map.resize();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache for pictures and sequences thumbnails
|
||||||
|
this._picThumbUrl = {};
|
||||||
|
this._seqThumbPicId = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change map rendering between small or wide
|
||||||
|
*
|
||||||
|
* @param {boolean} isWide True to make wide
|
||||||
|
*/
|
||||||
|
setWide(isWide) {
|
||||||
|
if (isWide) {
|
||||||
|
this.container.classList.remove("gvs-map-small");
|
||||||
|
} else {
|
||||||
|
this.container.classList.add("gvs-map-small");
|
||||||
|
}
|
||||||
|
this._map.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce component visibility (shown as a badge button)
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_minimize() {
|
||||||
|
this.container.classList.add("gvs-map-minimized");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show component as a classic widget (invert operation of minimize)
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_maximize() {
|
||||||
|
this.container.classList.remove("gvs-map-minimized");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create pictures/sequences vector tiles layer
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createPicturesTilesLayer() {
|
||||||
|
this._map.addSource("geovisio", {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [this.psv._myApi.getPicturesTilesUrl()],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.addLayer({
|
||||||
|
id: "sequences",
|
||||||
|
type: "line",
|
||||||
|
source: "geovisio",
|
||||||
|
"source-layer": "sequences",
|
||||||
|
...this._getSequencesLayerStyleProperties(),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.addLayer({
|
||||||
|
id: "pictures",
|
||||||
|
type: "circle",
|
||||||
|
source: "geovisio",
|
||||||
|
"source-layer": "pictures",
|
||||||
|
...this._getPicturesLayerStyleProperties(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map interaction events (pointer cursor, click)
|
||||||
|
this._picPopup = new maplibregl.Popup({
|
||||||
|
closeButton: false,
|
||||||
|
closeOnClick: false,
|
||||||
|
offset: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("mouseenter", "pictures", (e) => {
|
||||||
|
this._map.getCanvas().style.cursor = "pointer";
|
||||||
|
this._attachPreviewToPictures(e, "pictures");
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("mouseleave", "pictures", () => {
|
||||||
|
this._map.getCanvas().style.cursor = "";
|
||||||
|
this._picPopup.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("click", "pictures", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.psv.goToPicture(e.features[0].properties.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("mouseenter", "sequences", (e) => {
|
||||||
|
if (this._map.getZoom() <= 15) {
|
||||||
|
this._map.getCanvas().style.cursor = "pointer";
|
||||||
|
if (e.features[0].properties.id) {
|
||||||
|
this._attachPreviewToPictures(e, "sequences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("mouseleave", "sequences", () => {
|
||||||
|
this._map.getCanvas().style.cursor = "";
|
||||||
|
this._picPopup.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("click", "sequences", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.features[0].properties.id && this._map.getZoom() <= 15) {
|
||||||
|
this._getPictureIdForSequence(e.features[0].properties.id).then(
|
||||||
|
(picId) => {
|
||||||
|
if (picId) {
|
||||||
|
this.psv.goToPicture(picId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._map.on("click", (e) => {
|
||||||
|
if (e.defaultPrevented === false) {
|
||||||
|
this._picPopup.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre paint/layout properties for pictures layer
|
||||||
|
* This is useful when selected picture changes to allow partial update
|
||||||
|
*
|
||||||
|
* @returns {object} Paint/layout properties
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getPicturesLayerStyleProperties() {
|
||||||
|
return {
|
||||||
|
paint: {
|
||||||
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 14, 2, 22, 9],
|
||||||
|
"circle-color": "#FF6F00",
|
||||||
|
"circle-opacity": ["interpolate", ["linear"], ["zoom"], 14, 0, 15, 1],
|
||||||
|
"circle-stroke-color": "#ffffff",
|
||||||
|
"circle-stroke-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
17,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
2,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
layout: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapLibre paint/layout properties for sequences layer
|
||||||
|
*
|
||||||
|
* @returns {object} Paint/layout properties
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getSequencesLayerStyleProperties() {
|
||||||
|
return {
|
||||||
|
paint: {
|
||||||
|
"line-width": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
0,
|
||||||
|
0.5,
|
||||||
|
10,
|
||||||
|
2,
|
||||||
|
14,
|
||||||
|
4,
|
||||||
|
16,
|
||||||
|
5,
|
||||||
|
22,
|
||||||
|
3,
|
||||||
|
],
|
||||||
|
"line-color": "#FF6F00",
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
"line-cap": "square",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a preview popup to a single picture.
|
||||||
|
* This is a mouseenter over pictures event handler
|
||||||
|
*
|
||||||
|
* @param {object} e Event data
|
||||||
|
* @param {string} from Calling layer name
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_attachPreviewToPictures(e, from) {
|
||||||
|
let f = e.features[0];
|
||||||
|
|
||||||
|
let coordinates =
|
||||||
|
from === "pictures" ? f.geometry.coordinates.slice() : e.lngLat;
|
||||||
|
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
|
||||||
|
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display thumbnail
|
||||||
|
this._picPopup
|
||||||
|
.setLngLat(coordinates)
|
||||||
|
.setHTML(`<i class="gvs-map-thumb">Loading...</i>`)
|
||||||
|
.addTo(this._map);
|
||||||
|
|
||||||
|
this._picPopup._loading = f.properties.id;
|
||||||
|
|
||||||
|
const p =
|
||||||
|
from === "pictures"
|
||||||
|
? this._getPictureThumbURL(f.properties.id)
|
||||||
|
: this._getPictureIdForSequence(f.properties.id).then((picId) =>
|
||||||
|
this._getPictureThumbURL(picId)
|
||||||
|
);
|
||||||
|
|
||||||
|
p.then((thumbUrl) => {
|
||||||
|
if (this._picPopup._loading === f.properties.id) {
|
||||||
|
delete this._picPopup._loading;
|
||||||
|
|
||||||
|
if (thumbUrl) {
|
||||||
|
this._picPopup.setHTML(
|
||||||
|
`<img class="gvs-map-thumb" src="${thumbUrl}" alt="Thumbnail of hovered picture" />`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this._picPopup.setHTML(`<i>No thumbnail</i>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get picture URL for a given picture ID
|
||||||
|
*
|
||||||
|
* @param {string} picId The picture ID
|
||||||
|
* @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getPictureThumbURL(picId) {
|
||||||
|
let res = null;
|
||||||
|
|
||||||
|
if (picId) {
|
||||||
|
if (this._picThumbUrl[picId] !== undefined) {
|
||||||
|
res =
|
||||||
|
typeof this._picThumbUrl[picId] === "string"
|
||||||
|
? Promise.resolve(this._picThumbUrl[picId])
|
||||||
|
: this._picThumbUrl[picId];
|
||||||
|
} else {
|
||||||
|
this._picThumbUrl[picId] = this.psv._myApi
|
||||||
|
.getPictureThumbnailUrl(picId)
|
||||||
|
.then((url) => {
|
||||||
|
if (url) {
|
||||||
|
this._picThumbUrl[picId] = url;
|
||||||
|
return url;
|
||||||
|
} else {
|
||||||
|
this._picThumbUrl[picId] = null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this._picThumbUrl[picId] = null;
|
||||||
|
});
|
||||||
|
res = this._picThumbUrl[picId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a picture thumbnail URL for a given sequence
|
||||||
|
*
|
||||||
|
* @param {string} seqId The sequence ID
|
||||||
|
* @returns {Promise} Promise resolving on picture thumbnail URL, or null on timeout
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getPictureIdForSequence(seqId) {
|
||||||
|
let res;
|
||||||
|
if (this._seqThumbPicId[seqId] !== undefined) {
|
||||||
|
if (typeof this._seqThumbPicId[seqId] === "string") {
|
||||||
|
res = Promise.resolve(this._seqThumbPicId[seqId]);
|
||||||
|
} else if (this._seqThumbPicId[seqId] === null) {
|
||||||
|
res = Promise.resolve(null);
|
||||||
|
} else {
|
||||||
|
res = this._seqThumbPicId[seqId];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._seqThumbPicId[seqId] = this.psv._myApi
|
||||||
|
.getPictureThumbnailForSequence(seqId)
|
||||||
|
.then((picMeta) => {
|
||||||
|
if (picMeta) {
|
||||||
|
const thumbAsset = Object.values(picMeta.assets).find((a) =>
|
||||||
|
a?.roles?.includes("thumbnail")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (thumbAsset) {
|
||||||
|
this._seqThumbPicId[seqId] = picMeta.id;
|
||||||
|
this._picThumbUrl[picMeta.id] = thumbAsset.href;
|
||||||
|
return picMeta.id;
|
||||||
|
} else {
|
||||||
|
this._seqThumbPicId[seqId] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._seqThumbPicId[seqId] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this._seqThumbPicId[seqId] = null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
res = this._seqThumbPicId[seqId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ready-to-use picture marker
|
||||||
|
*
|
||||||
|
* @returns {maplibregl.Marker} The generated marker
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getPictureMarker() {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = MarkerSVG;
|
||||||
|
return new maplibregl.Marker({
|
||||||
|
element: img,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening to picture changes in PSV
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_listenToViewerEvents() {
|
||||||
|
// Switched picture
|
||||||
|
this.psv.on("picture-loaded", (e, d) => {
|
||||||
|
// Show marker corresponding to selection
|
||||||
|
this._picMarker
|
||||||
|
.setLngLat([d.lon, d.lat])
|
||||||
|
.setRotation(d.x)
|
||||||
|
.addTo(this._map);
|
||||||
|
|
||||||
|
// Move map to picture coordinates
|
||||||
|
this._map.flyTo({
|
||||||
|
center: [d.lon, d.lat],
|
||||||
|
zoom: this._map.getZoom() < 15 ? 20 : this._map.getZoom(),
|
||||||
|
maxDuration: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picture view rotated
|
||||||
|
this.psv.on("view-rotated", (e, d) => {
|
||||||
|
this._picMarker.setRotation(d.x);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/lib/MiniComponentButtons.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import './css/MiniComponentButtons.css';
|
||||||
|
import Map from './Map';
|
||||||
|
import MinimizeIcon from './img/minimize.svg';
|
||||||
|
import ExpandIcon from './img/expand.svg';
|
||||||
|
import PictureIcon from './img/picture.svg';
|
||||||
|
import MapIcon from './img/map.svg';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mini component buttons is a helper to create top-right corner buttons on reduced version of map/viewer.
|
||||||
|
* It offers a same design style for both, and handle events.
|
||||||
|
*
|
||||||
|
* @param {Viewer|Map} parent The parent container
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
export default class MiniComponentButtons {
|
||||||
|
constructor(parent) {
|
||||||
|
this._parentName = parent instanceof Map ? "map" : "psv";
|
||||||
|
this._parentLabel = this._parentName === "map" ? "map" : "picture";
|
||||||
|
this._parent = parent;
|
||||||
|
this._createButtonsDOM();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the DOM
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_createButtonsDOM() {
|
||||||
|
// Button in bottom left corner (mini widget hidden)
|
||||||
|
this.badgeBtn = document.createElement("button");
|
||||||
|
this.badgeBtn.classList.add("gvs-mini-buttons-badge");
|
||||||
|
const badgeImg = document.createElement("img");
|
||||||
|
badgeImg.src = this._parentName === "map" ? MapIcon : PictureIcon;
|
||||||
|
badgeImg.alt = "Expand";
|
||||||
|
badgeImg.title = `Show the ${this._parentLabel}`;
|
||||||
|
this.badgeBtn.appendChild(badgeImg);
|
||||||
|
this.badgeBtn.addEventListener("click", () => {
|
||||||
|
this.badgeBtn.classList.remove("gvs-visible");
|
||||||
|
this._parent._maximize();
|
||||||
|
});
|
||||||
|
(this._parentName === "map" ? this._parent.psv.parent : this._parent.parent).appendChild(this.badgeBtn);
|
||||||
|
|
||||||
|
// Buttons in map/viewer top right corner (mini widget visible)
|
||||||
|
this.container = document.createElement("div");
|
||||||
|
this.container.classList.add("gvs-mini-buttons");
|
||||||
|
|
||||||
|
const btnMinimize = document.createElement("button");
|
||||||
|
const btnMinimizeIcon = document.createElement("img");
|
||||||
|
btnMinimizeIcon.src = MinimizeIcon;
|
||||||
|
btnMinimizeIcon.alt = "Minimize";
|
||||||
|
btnMinimizeIcon.title = `Hide the ${this._parentLabel}, you can show it again using button in bottom left corner`;
|
||||||
|
btnMinimize.addEventListener("click", () => {
|
||||||
|
this._parent._minimize();
|
||||||
|
this.badgeBtn.classList.add("gvs-visible");
|
||||||
|
});
|
||||||
|
btnMinimize.appendChild(btnMinimizeIcon);
|
||||||
|
this.container.appendChild(btnMinimize);
|
||||||
|
|
||||||
|
const btnExpand = document.createElement("button");
|
||||||
|
const btnExpandIcon = document.createElement("img");
|
||||||
|
btnExpandIcon.src = ExpandIcon;
|
||||||
|
btnExpandIcon.alt = "Expand";
|
||||||
|
btnExpandIcon.title = `Make the ${this._parentLabel} appear in full page`;
|
||||||
|
btnExpand.appendChild(btnExpandIcon);
|
||||||
|
btnExpand.addEventListener("click", () => {
|
||||||
|
if(this._parentName === "map") { this._parent.psv.setMapWide(true); }
|
||||||
|
else { this._parent.setMapWide(false); }
|
||||||
|
});
|
||||||
|
this.container.appendChild(btnExpand);
|
||||||
|
|
||||||
|
this._parent.container.appendChild(this.container);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/lib/NextButton.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { AbstractButton, registerButton, DEFAULTS } from 'photo-sphere-viewer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Navigation bar next picture in sequence button class
|
||||||
|
* @augments {external:photo-sphere-viewer.buttons.AbstractButton}
|
||||||
|
* @memberof {external:photo-sphere-viewer.buttons}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class NextButton extends AbstractButton {
|
||||||
|
|
||||||
|
static id = 'sequence-next';
|
||||||
|
static icon = '<svg viewBox="0 0 100 100" version="1.1" id="svg4" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <path d="M 66.176728,53.93303 41.721163,78.388595 c -2.148533,2.14854 -5.68704,2.14854 -7.835573,0 -2.14854,-2.148533 -2.14854,-5.68704 0,-7.835572 L 54.359518,50.015045 33.822875,29.478403 c -2.14854,-2.14854 -2.14854,-5.68704 0,-7.835581 C 34.897115,20.568582 36.350225,20 37.74086,20 c 1.390103,0 2.843722,0.56865 3.917993,1.642822 l 24.518272,24.455565 c 2.14854,2.14854 2.14854,5.68704 0,7.83558 z" fill="currentColor" /></svg>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {external:photo-sphere-viewer.components.Navbar} navbar The navigation bar
|
||||||
|
*/
|
||||||
|
constructor(navbar) {
|
||||||
|
super(navbar, 'psv-button--hover-scale psv-sequence-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @description Goes to next picture in sequence
|
||||||
|
*/
|
||||||
|
onClick() {
|
||||||
|
try {
|
||||||
|
this.psv.goToNextPicture();
|
||||||
|
} catch(e) {
|
||||||
|
console.warn(e);
|
||||||
|
this.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULTS.lang[NextButton.id] = 'Next picture in sequence';
|
||||||
|
registerButton(NextButton, 'caption:left');
|
||||||
|
export default NextButton;
|
||||||
70
src/lib/PlayButton.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { AbstractButton, registerButton, DEFAULTS } from 'photo-sphere-viewer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Navigation bar play picture in sequence button class
|
||||||
|
* @augments {external:photo-sphere-viewer.buttons.AbstractButton}
|
||||||
|
* @memberof {external:photo-sphere-viewer.buttons}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class PlayButton extends AbstractButton {
|
||||||
|
|
||||||
|
static id = 'sequence-play';
|
||||||
|
static icon = '<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <path d="m 78.839122,44.639674 -50.40778,-33.54823 c -4.2989,-2.86606 -10.11502,0.25309 -10.11502,5.3948 v 67.01458 c 0,5.22643 5.81612,8.26069 10.11502,5.39481 l 50.40778,-33.54823 c 3.79286,-2.44456 3.79286,-8.17667 -7.1e-4,-10.70542 z" fill="currentColor" /> </svg> ';
|
||||||
|
static iconActive = '<svg version="1.1" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <path d="m 11.057615,10 v 80 h 31.72461 V 10 Z m 46.15821,0 v 80 h 31.72656 V 10 Z" fill="currentColor" /></svg>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {external:photo-sphere-viewer.components.Navbar} navbar The navigation bar
|
||||||
|
*/
|
||||||
|
constructor(navbar) {
|
||||||
|
super(navbar, 'psv-button--hover-scale psv-sequence-button');
|
||||||
|
|
||||||
|
this.psv.on("sequence-playing", this);
|
||||||
|
this.psv.on("sequence-stopped", this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.psv.off("sequence-playing", this);
|
||||||
|
this.psv.off("sequence-stopped", this);
|
||||||
|
super.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle events
|
||||||
|
*
|
||||||
|
* @param {Event} e Event metadata
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
handleEvent(e) {
|
||||||
|
/* eslint-disable */
|
||||||
|
switch (e.type) {
|
||||||
|
// @formatter:off
|
||||||
|
case "sequence-playing": this.toggleActive(true); break;
|
||||||
|
case "sequence-stopped": this.toggleActive(false); break;
|
||||||
|
// @formatter:on
|
||||||
|
}
|
||||||
|
/* eslint-enable */
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @description Goes to play picture in sequence
|
||||||
|
*/
|
||||||
|
onClick() {
|
||||||
|
if(this.psv.isSequencePlaying()) {
|
||||||
|
this.toggleActive(false);
|
||||||
|
this.psv.stopSequence();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.toggleActive(true);
|
||||||
|
this.psv.playSequence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULTS.lang[PlayButton.id] = 'Play/pause navigation';
|
||||||
|
registerButton(PlayButton, 'caption:left');
|
||||||
|
export default PlayButton;
|
||||||
38
src/lib/PreviousButton.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { AbstractButton, registerButton, DEFAULTS } from 'photo-sphere-viewer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Navigation bar previous picture in sequence button class
|
||||||
|
* @augments {external:photo-sphere-viewer.buttons.AbstractButton}
|
||||||
|
* @memberof {external:photo-sphere-viewer.buttons}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
class PreviousButton extends AbstractButton {
|
||||||
|
|
||||||
|
static id = 'sequence-prev';
|
||||||
|
static icon = '<svg viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> <path d="m 33.823272,53.93303 24.455565,24.455565 c 2.148533,2.14854 5.68704,2.14854 7.835573,0 2.14854,-2.148533 2.14854,-5.68704 0,-7.835572 L 45.640482,50.015045 66.177125,29.478403 c 2.14854,-2.14854 2.14854,-5.68704 0,-7.835581 C 65.102885,20.568582 63.649775,20 62.25914,20 c -1.390103,0 -2.843722,0.56865 -3.917993,1.642822 L 33.822875,46.098387 c -2.14854,2.14854 -2.14854,5.68704 0,7.83558 z" fill="currentColor" /> </svg> ';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {external:photo-sphere-viewer.components.Navbar} navbar The navigation bar
|
||||||
|
*/
|
||||||
|
constructor(navbar) {
|
||||||
|
super(navbar, 'psv-button--hover-scale psv-sequence-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @override
|
||||||
|
* @description Goes to previous picture in sequence
|
||||||
|
*/
|
||||||
|
onClick() {
|
||||||
|
try {
|
||||||
|
this.psv.goToPrevPicture();
|
||||||
|
} catch(e) {
|
||||||
|
console.warn(e);
|
||||||
|
this.disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULTS.lang[PreviousButton.id] = 'Previous picture in sequence';
|
||||||
|
registerButton(PreviousButton, 'caption:left');
|
||||||
|
export default PreviousButton;
|
||||||
605
src/lib/Viewer.js
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
import "./css/Viewer.css";
|
||||||
|
import "photo-sphere-viewer/dist/photo-sphere-viewer.css";
|
||||||
|
import { Viewer as PSViewer } from "photo-sphere-viewer";
|
||||||
|
import { EquirectangularTilesAdapter } from "photo-sphere-viewer/dist/adapters/equirectangular-tiles";
|
||||||
|
import {
|
||||||
|
VirtualTourPlugin,
|
||||||
|
MODE_GPS as VTP_MODE_GPS,
|
||||||
|
MODE_3D as VTP_MODE_3D,
|
||||||
|
MODE_SERVER as VTP_MODE_SERVER,
|
||||||
|
} from "photo-sphere-viewer/dist/plugins/virtual-tour";
|
||||||
|
import "photo-sphere-viewer/dist/plugins/virtual-tour.css";
|
||||||
|
import API from "./API";
|
||||||
|
import "./PreviousButton";
|
||||||
|
import "./NextButton";
|
||||||
|
import "./PlayButton";
|
||||||
|
import Map from "./Map";
|
||||||
|
import MiniComponentButtons from "./MiniComponentButtons";
|
||||||
|
import LoaderImgBase from "./img/loader_base.jpg";
|
||||||
|
import LoaderImgTile0 from "./img/loader_0.jpg";
|
||||||
|
import LoaderImgTile1 from "./img/loader_1.jpg";
|
||||||
|
|
||||||
|
const BASE_PANORAMA = {
|
||||||
|
baseUrl: LoaderImgBase,
|
||||||
|
width: 1280,
|
||||||
|
cols: 2,
|
||||||
|
rows: 1,
|
||||||
|
tileUrl: (col) => (col === 0 ? LoaderImgTile0 : LoaderImgTile1),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewer is the component allowing display of 360° pictures.
|
||||||
|
*
|
||||||
|
* @augments {external:photo-sphere-viewer.Viewer}
|
||||||
|
* @fires picture-loaded
|
||||||
|
* @param {string|Node} container The DOM element to create viewer into
|
||||||
|
* @param {string} endpoint URL to API to use (must be a [STAC API Item Search endpoint](https://github.com/radiantearth/stac-api-spec/tree/master/item-search))
|
||||||
|
* @param {object} [options] Viewer options
|
||||||
|
* @param {string} [options.picId] Initial picture identifier to display
|
||||||
|
* @param {number[]} [options.position] Initial position to go to (in [lat, lon] format)
|
||||||
|
* @param {boolean} [options.player=true] Enable sequence player buttons (next/prev/play buttons)
|
||||||
|
* @param {boolean|object} [options.map=false] Enable contextual map for locating pictures. Setting to true or passing an object enables the map. Various settings can be passed, either the ones defined here, or any of [MapLibre GL settings](https://maplibre.org/maplibre-gl-js-docs/api/map/#map-parameters)
|
||||||
|
* @param {string} [options.map.picturesTiles] URL for fetching pictures vector tiles (if map is enabled, defaults to GeoVisio /api/map endpoint)
|
||||||
|
* @param {boolean} [options.map.startWide] Show the map as main element at startup (defaults to false, viewer is wider at start)
|
||||||
|
* @param {number} [options.map.minZoom=0] The minimum zoom level of the map (0-24).
|
||||||
|
* @param {number} [options.map.maxZoom=22] The maximum zoom level of the map (0-24).
|
||||||
|
* @param {object|string} [options.map.style] The map's MapLibre style. This must be an a JSON object conforming to the schema described in the [MapLibre Style Specification](https://maplibre.org/maplibre-gl-js-docs/style-spec/), or a URL to such JSON.
|
||||||
|
* @param {(boolean|string)} [options.map.hash=false] If `true`, the map's position (zoom, center latitude, center longitude, bearing, and pitch) will be synced with the hash fragment of the page's URL.
|
||||||
|
* For example, `http://path/to/my/page.html#2.59/39.26/53.07/-24.1/60`.
|
||||||
|
* An additional string may optionally be provided to indicate a parameter-styled hash, e.g. http://path/to/my/page.html#map=2.59/39.26/53.07/-24.1/60&foo=bar, where foo is a custom parameter and bar is an arbitrary hash distinct from the map hash.
|
||||||
|
* @param {external:maplibre-gl.LngLatLike} [options.map.center=[0, 0]] The initial geographical centerpoint of the map. If `center` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `[0, 0]` Note: MapLibre GL uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match GeoJSON.
|
||||||
|
* @param {number} [options.map.zoom=0] The initial zoom level of the map. If `zoom` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`.
|
||||||
|
* @param {external:maplibre-gl.LngLatBoundsLike} [options.map.bounds] The initial bounds of the map. If `bounds` is specified, it overrides `center` and `zoom` constructor options.
|
||||||
|
*/
|
||||||
|
class Viewer extends PSViewer {
|
||||||
|
constructor(container, endpoint, options = {}) {
|
||||||
|
super({
|
||||||
|
container,
|
||||||
|
adapter: [
|
||||||
|
EquirectangularTilesAdapter,
|
||||||
|
{
|
||||||
|
showErrorTile: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
caption: "GeoVisio",
|
||||||
|
panorama: BASE_PANORAMA,
|
||||||
|
minFov: 5,
|
||||||
|
navbar: ["zoom"]
|
||||||
|
.concat(
|
||||||
|
options.player === undefined || options.player
|
||||||
|
? ["sequence-prev", "sequence-play", "sequence-next"]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
.concat(["caption", "move"]),
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
VirtualTourPlugin,
|
||||||
|
{
|
||||||
|
positionMode: VTP_MODE_GPS,
|
||||||
|
renderMode: VTP_MODE_3D,
|
||||||
|
dataMode: VTP_MODE_SERVER,
|
||||||
|
preload: true,
|
||||||
|
rotateSpeed: false,
|
||||||
|
getNode: (picId) => {
|
||||||
|
if (!picId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return fetch(this._myApi.getPictureMetadataUrl(picId))
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((metadata) => {
|
||||||
|
metadata = metadata.features.pop();
|
||||||
|
if (!metadata) {
|
||||||
|
throw new Error(
|
||||||
|
"Picture with ID " + picId + " was not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const horizontalFov =
|
||||||
|
metadata.properties["pers:interior_orientation"] &&
|
||||||
|
metadata.properties["pers:interior_orientation"][
|
||||||
|
"field_of_view"
|
||||||
|
]
|
||||||
|
? parseInt(
|
||||||
|
metadata.properties["pers:interior_orientation"][
|
||||||
|
"field_of_view"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
: 70;
|
||||||
|
const is360 = horizontalFov === 360;
|
||||||
|
const hdUrl = (
|
||||||
|
Object.values(metadata.assets).find((a) =>
|
||||||
|
a.roles.includes("data")
|
||||||
|
) || {}
|
||||||
|
).href;
|
||||||
|
const matrix = metadata.properties["tiles:tile_matrix_sets"]
|
||||||
|
? metadata.properties["tiles:tile_matrix_sets"].geovisio
|
||||||
|
: null;
|
||||||
|
const prev = metadata.links.find(
|
||||||
|
(l) => l.rel === "prev" && l.type === "application/geo+json"
|
||||||
|
);
|
||||||
|
const next = metadata.links.find(
|
||||||
|
(l) => l.rel === "next" && l.type === "application/geo+json"
|
||||||
|
);
|
||||||
|
const baseUrlWebp = Object.values(metadata.assets).find(
|
||||||
|
(a) => a.roles.includes("visual") && a.type === "image/webp"
|
||||||
|
);
|
||||||
|
const baseUrlJpeg = Object.values(metadata.assets).find(
|
||||||
|
(a) => a.roles.includes("visual") && a.type === "image/jpeg"
|
||||||
|
);
|
||||||
|
const tileUrl =
|
||||||
|
metadata?.asset_templates?.tiles_webp ||
|
||||||
|
metadata?.asset_templates?.tiles;
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
id: metadata.id,
|
||||||
|
links: metadata.links
|
||||||
|
.filter(
|
||||||
|
(l) =>
|
||||||
|
["next", "prev"].includes(l.rel) &&
|
||||||
|
l.type === "application/geo+json"
|
||||||
|
)
|
||||||
|
.map((l) => ({
|
||||||
|
nodeId: l.id,
|
||||||
|
position: l.geometry.coordinates,
|
||||||
|
})),
|
||||||
|
panorama: is360
|
||||||
|
? {
|
||||||
|
baseUrl: (baseUrlWebp || baseUrlJpeg).href,
|
||||||
|
cols: matrix && matrix.tileMatrix[0].matrixWidth,
|
||||||
|
rows: matrix && matrix.tileMatrix[0].matrixHeight,
|
||||||
|
width:
|
||||||
|
matrix &&
|
||||||
|
matrix.tileMatrix[0].matrixWidth *
|
||||||
|
matrix.tileMatrix[0].tileWidth,
|
||||||
|
tileUrl:
|
||||||
|
matrix &&
|
||||||
|
((col, row) =>
|
||||||
|
tileUrl.href
|
||||||
|
.replace(/\{TileCol\}/g, col)
|
||||||
|
.replace(/\{TileRow\}/g, row)),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
// Flat pictures are shown only using a cropped base panorama
|
||||||
|
baseUrl: hdUrl,
|
||||||
|
basePanoData: (img) => {
|
||||||
|
const verticalFov =
|
||||||
|
(horizontalFov * img.height) / img.width;
|
||||||
|
const panoWidth = (img.width * 360) / horizontalFov;
|
||||||
|
const panoHeight = (img.height * 180) / verticalFov;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullWidth: panoWidth,
|
||||||
|
fullHeight: panoHeight,
|
||||||
|
croppedWidth: img.width,
|
||||||
|
croppedHeight: img.height,
|
||||||
|
croppedX: (panoWidth - img.width) / 2,
|
||||||
|
croppedY: (panoHeight - img.height) / 2,
|
||||||
|
poseHeading:
|
||||||
|
metadata.properties["view:azimuth"] || 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// This is only to mock loading of tiles (which are not available for flat pictures)
|
||||||
|
cols: 2,
|
||||||
|
rows: 1,
|
||||||
|
width: 2,
|
||||||
|
tileUrl: () => "",
|
||||||
|
},
|
||||||
|
position: metadata.geometry.coordinates,
|
||||||
|
sequence: {
|
||||||
|
id: metadata.collection,
|
||||||
|
nextPic: next ? next.id : undefined,
|
||||||
|
prevPic: prev ? prev.id : undefined,
|
||||||
|
},
|
||||||
|
sphereCorrection: metadata.properties["view:azimuth"]
|
||||||
|
? {
|
||||||
|
pan:
|
||||||
|
-metadata.properties["view:azimuth"] *
|
||||||
|
(Math.PI / 180),
|
||||||
|
}
|
||||||
|
: { pan: 0 },
|
||||||
|
horizontalFov,
|
||||||
|
};
|
||||||
|
console.log(res);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
this._myApi = new API(endpoint, options?.map?.picturesTiles);
|
||||||
|
this._myVTour = this.getPlugin(VirtualTourPlugin);
|
||||||
|
this._sequencePlaying = false;
|
||||||
|
this._prevSequence = null;
|
||||||
|
|
||||||
|
// Call appropriate functions at start according to initial options
|
||||||
|
if (typeof options === "object") {
|
||||||
|
const onReady = () => {
|
||||||
|
if (options.picId) {
|
||||||
|
this.goToPicture(options.picId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.position) {
|
||||||
|
this.goToPosition(...options.position).catch((e) => console.log(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.map) {
|
||||||
|
this._map = new Map(this, options.map);
|
||||||
|
this._map._map.once("load", onReady);
|
||||||
|
this._mapWide = false;
|
||||||
|
this.container.classList.add("psv--has-map");
|
||||||
|
this._miniButtons = new MiniComponentButtons(this);
|
||||||
|
if (typeof options.map === "object" && options.map.startWide) {
|
||||||
|
this.setMapWide(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom events handlers
|
||||||
|
this.on("position-updated", (e, position) => {
|
||||||
|
/**
|
||||||
|
* Event for viewer rotation
|
||||||
|
*
|
||||||
|
* @event view-rotated
|
||||||
|
* @type {object}
|
||||||
|
* @property {number} x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West)
|
||||||
|
* @property {number} y New y position (in degrees)
|
||||||
|
*/
|
||||||
|
this.trigger("view-rotated", this._positionToXY(position));
|
||||||
|
|
||||||
|
this._onTilesStartLoading();
|
||||||
|
});
|
||||||
|
|
||||||
|
this._myVTour.on("node-changed", (e, nodeId) => {
|
||||||
|
if (nodeId) {
|
||||||
|
const picMeta = this.getPictureMetadata();
|
||||||
|
// Set rotation angle + fov on initial load / sequence change
|
||||||
|
const currentSequence = picMeta.sequence.id;
|
||||||
|
if (
|
||||||
|
!this._prevSequence ||
|
||||||
|
currentSequence !== this._prevSequence ||
|
||||||
|
picMeta.horizontalFov < 360
|
||||||
|
) {
|
||||||
|
this.setOption(
|
||||||
|
"maxFov",
|
||||||
|
Math.min((picMeta.horizontalFov * 3) / 4, 90)
|
||||||
|
);
|
||||||
|
this.rotate({
|
||||||
|
longitude: -picMeta.sphereCorrection.pan,
|
||||||
|
latitude: 0,
|
||||||
|
});
|
||||||
|
this.zoom(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._prevSequence = currentSequence;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event for picture load (low-resolution image is loaded)
|
||||||
|
*
|
||||||
|
* @event picture-loaded
|
||||||
|
* @type {object}
|
||||||
|
* @property {string} picId The picture unique identifier
|
||||||
|
* @property {number} lon Longitude (WGS84)
|
||||||
|
* @property {number} lat Latitude (WGS84)
|
||||||
|
* @property {number} x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West)
|
||||||
|
* @property {number} y New y position (in degrees)
|
||||||
|
*/
|
||||||
|
this.trigger("picture-loaded", {
|
||||||
|
...this._positionToXY(this.getPosition()),
|
||||||
|
picId: nodeId,
|
||||||
|
lon: picMeta.position[0],
|
||||||
|
lat: picMeta.position[1],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this._onTilesStartLoading();
|
||||||
|
|
||||||
|
// Update prev/next picture in sequence buttons
|
||||||
|
if (this.navbar.getButton("sequence-prev")) {
|
||||||
|
if (
|
||||||
|
this.getPictureMetadata() &&
|
||||||
|
this.getPictureMetadata().sequence.prevPic
|
||||||
|
) {
|
||||||
|
this.navbar.getButton("sequence-prev").enable();
|
||||||
|
} else {
|
||||||
|
this.navbar.getButton("sequence-prev").disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.navbar.getButton("sequence-next")) {
|
||||||
|
if (
|
||||||
|
this.getPictureMetadata() &&
|
||||||
|
this.getPictureMetadata().sequence.nextPic
|
||||||
|
) {
|
||||||
|
this.navbar.getButton("sequence-next").enable();
|
||||||
|
} else {
|
||||||
|
this.navbar.getButton("sequence-next").disable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts result from getPosition or position-updated event into x/y coordinates
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
* @param {object} pos latitude/longitude as given by PSV
|
||||||
|
* @returns {object} Same coordinates as x/y and in degrees
|
||||||
|
*/
|
||||||
|
_positionToXY(pos) {
|
||||||
|
return {
|
||||||
|
x: pos.longitude * (180 / Math.PI),
|
||||||
|
y: pos.latitude * (180 / Math.PI),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for loading a new range of tiles
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_onTilesStartLoading() {
|
||||||
|
if (this._tilesQueueTimer) {
|
||||||
|
clearInterval(this._tilesQueueTimer);
|
||||||
|
delete this._tilesQueueTimer;
|
||||||
|
}
|
||||||
|
this._tilesQueueTimer = setInterval(() => {
|
||||||
|
if (Object.keys(this.adapter.queue.tasks).length === 0) {
|
||||||
|
if (this._myVTour.prop.currentNode) {
|
||||||
|
/**
|
||||||
|
* Event launched when all visible tiles of a picture are loaded
|
||||||
|
*
|
||||||
|
* @event picture-tiles-loaded
|
||||||
|
* @type {object}
|
||||||
|
* @property {string} picId The picture unique identifier
|
||||||
|
*/
|
||||||
|
this.trigger("picture-tiles-loaded", {
|
||||||
|
picId: this._myVTour.prop.currentNode.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
clearInterval(this._tilesQueueTimer);
|
||||||
|
delete this._tilesQueueTimer;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click handler for next/prev navbar buttons
|
||||||
|
*
|
||||||
|
* @param {('next'|'prev')} type Set if it's next or previous button
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_onNextPrevPicClick(type) {
|
||||||
|
if (
|
||||||
|
this.getPictureMetadata() &&
|
||||||
|
this.getPictureMetadata().sequence[type + "Pic"]
|
||||||
|
) {
|
||||||
|
// Actually change current picture
|
||||||
|
if (type === "prev") {
|
||||||
|
this.goToPrevPicture();
|
||||||
|
} else if (type === "next") {
|
||||||
|
this.goToNextPicture();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get position of sphere currently shown to user
|
||||||
|
*
|
||||||
|
* @returns {object} Position in format { x: heading in degrees (0° = North, 90° = East, 180° = South, 270° = West), y: top/bottom position in degrees (-90° = bottom, 0° = front, 90° = top) }
|
||||||
|
*/
|
||||||
|
getXY() {
|
||||||
|
return this._positionToXY(this.getPosition());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access currently shown picture metadata
|
||||||
|
*
|
||||||
|
* @returns {object} Picture metadata
|
||||||
|
*/
|
||||||
|
getPictureMetadata() {
|
||||||
|
return this._myVTour.prop.currentNode
|
||||||
|
? Object.assign({}, this._myVTour.prop.currentNode)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Access the map object (if any)
|
||||||
|
* Allows you to call any of [the MapLibre GL Map function](https://maplibre.org/maplibre-gl-js-docs/api/map/) for advanced map management
|
||||||
|
*
|
||||||
|
* @returns {null|external:maplibre-gl.Map} The map
|
||||||
|
*/
|
||||||
|
getMap() {
|
||||||
|
return this._map ? this._map._map : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays in viewer specified picture
|
||||||
|
*
|
||||||
|
* @param {string} picId The picture unique identifier
|
||||||
|
*/
|
||||||
|
goToPicture(picId) {
|
||||||
|
// Actually move to wanted picture
|
||||||
|
this._myVTour.setCurrentNode(picId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goes continuously to next picture in sequence as long as possible
|
||||||
|
*/
|
||||||
|
playSequence() {
|
||||||
|
this._sequencePlaying = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event for sequence starting to play
|
||||||
|
*
|
||||||
|
* @event sequence-playing
|
||||||
|
* @type {object}
|
||||||
|
*/
|
||||||
|
this.trigger("sequence-playing");
|
||||||
|
|
||||||
|
const nextPicturePlay = () => {
|
||||||
|
if (this._sequencePlaying) {
|
||||||
|
this.once("picture-tiles-loaded", () => {
|
||||||
|
this._playTimer = setTimeout(() => {
|
||||||
|
nextPicturePlay();
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.goToNextPicture();
|
||||||
|
} catch (e) {
|
||||||
|
this.stopSequence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop playing if user clicks on image
|
||||||
|
this.on("click", (e) => {
|
||||||
|
console.log(e);
|
||||||
|
this.stopSequence();
|
||||||
|
});
|
||||||
|
|
||||||
|
nextPicturePlay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops playing current sequence
|
||||||
|
*/
|
||||||
|
stopSequence() {
|
||||||
|
this._sequencePlaying = false;
|
||||||
|
|
||||||
|
if (this._playTimer) {
|
||||||
|
clearTimeout(this._playTimer);
|
||||||
|
delete this._playTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event for sequence stopped playing
|
||||||
|
*
|
||||||
|
* @event sequence-stopped
|
||||||
|
* @type {object}
|
||||||
|
*/
|
||||||
|
this.trigger("sequence-stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is there any sequence being played right now ?
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if sequence is playing
|
||||||
|
*/
|
||||||
|
isSequencePlaying() {
|
||||||
|
return this._sequencePlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays next picture in current sequence (if any)
|
||||||
|
*/
|
||||||
|
goToNextPicture() {
|
||||||
|
if (!this.getPictureMetadata()) {
|
||||||
|
throw new Error("No picture currently selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = this.getPictureMetadata().sequence.nextPic;
|
||||||
|
if (next) {
|
||||||
|
this.goToPicture(next);
|
||||||
|
} else {
|
||||||
|
throw new Error("No next picture available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays previous picture in current sequence (if any)
|
||||||
|
*/
|
||||||
|
goToPrevPicture() {
|
||||||
|
if (!this.getPictureMetadata()) {
|
||||||
|
throw new Error("No picture currently selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = this.getPictureMetadata().sequence.prevPic;
|
||||||
|
if (prev) {
|
||||||
|
this.goToPicture(prev);
|
||||||
|
} else {
|
||||||
|
throw new Error("No previous picture available");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays in viewer a picture near to given coordinates
|
||||||
|
*
|
||||||
|
* @param {number} lat Latitude (WGS84)
|
||||||
|
* @param {number} lon Longitude (WGS84)
|
||||||
|
* @returns {Promise} Resolves on picture ID if picture found, otherwise rejects
|
||||||
|
*/
|
||||||
|
goToPosition(lat, lon) {
|
||||||
|
return fetch(this._myApi.getPicturesAroundCoordinatesUrl(lat, lon))
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => {
|
||||||
|
if (res.features.length > 0) {
|
||||||
|
this.goToPicture(res.features[0].id);
|
||||||
|
return res.features[0].id;
|
||||||
|
} else {
|
||||||
|
return Promise.reject(
|
||||||
|
new Error("No picture found nearby given coordinates")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the map shown as main element instead of viewer (wide map mode) ?
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if map is wider than viewer
|
||||||
|
*/
|
||||||
|
isMapWide() {
|
||||||
|
if (!this._map) {
|
||||||
|
throw new Error("Map is not enabled");
|
||||||
|
}
|
||||||
|
return this._mapWide;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the map size (either small in a corner or wider than viewer)
|
||||||
|
*
|
||||||
|
* @param {boolean} wideMap True to make map wider than viewer
|
||||||
|
*/
|
||||||
|
setMapWide(wideMap) {
|
||||||
|
if (!this._map) {
|
||||||
|
throw new Error("Map is not enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change map size
|
||||||
|
this._mapWide = wideMap;
|
||||||
|
this._map.setWide(this._mapWide);
|
||||||
|
|
||||||
|
// Change viewer size
|
||||||
|
if (this._mapWide) {
|
||||||
|
this.container.classList.add("gvs-small");
|
||||||
|
} else {
|
||||||
|
this.container.classList.remove("gvs-small");
|
||||||
|
}
|
||||||
|
this.autoSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduce component visibility (shown as a badge button)
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_minimize() {
|
||||||
|
this.container.classList.add("gvs-minimized");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show component as a classic widget (invert operation of minimize)
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_maximize() {
|
||||||
|
this.container.classList.remove("gvs-minimized");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Viewer;
|
||||||
37
src/lib/css/Map.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/* Map layout */
|
||||||
|
.psv-map {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv-map .maplibregl-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small map layout */
|
||||||
|
.psv-map.gvs-map-small {
|
||||||
|
z-index: 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv-map.gvs-map-small .maplibregl-map {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv-map.gvs-map-small .maplibregl-ctrl-attrib,
|
||||||
|
.psv-map.gvs-map-small .maplibregl-ctrl-top-right .maplibregl-ctrl-group {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Picture thumbnail on map */
|
||||||
|
.gvs-map-thumb {
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 150px;
|
||||||
|
height: 75px;
|
||||||
|
}
|
||||||
67
src/lib/css/MiniComponentButtons.css
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Visible mini widget buttons
|
||||||
|
*/
|
||||||
|
.gvs-mini-buttons {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
border-top-right-radius: 10px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gvs-mini-buttons button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: white;
|
||||||
|
margin: 0 3px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gvs-mini-buttons button img {
|
||||||
|
height: 16px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gvs-mini-buttons button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide when parent is wide */
|
||||||
|
.psv-map:not(.gvs-map-small) .gvs-mini-buttons,
|
||||||
|
.psv-container:not(.gvs-small) .gvs-mini-buttons {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Badge button (mini widget hidden)
|
||||||
|
*/
|
||||||
|
.gvs-mini-buttons-badge {
|
||||||
|
display: none;
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
border: none;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background-color: #1565C0;
|
||||||
|
color: white;
|
||||||
|
z-index: 1000;
|
||||||
|
box-shadow: 2px 2px 5px #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gvs-mini-buttons-badge img {
|
||||||
|
vertical-align: middle;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gvs-mini-buttons-badge.gvs-visible {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
37
src/lib/css/Viewer.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/* Reduce navbar when map is visible */
|
||||||
|
.psv--has-map .psv-navbar {
|
||||||
|
left: 340px;
|
||||||
|
right: 0;
|
||||||
|
border-top-left-radius: 10px;
|
||||||
|
width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv--has-map {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small widget layout */
|
||||||
|
.psv-container.gvs-small,
|
||||||
|
.psv-map.gvs-map-small {
|
||||||
|
position: absolute;
|
||||||
|
top: unset;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
width: 320px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.psv-container.gvs-small {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimized widget layout */
|
||||||
|
.psv-container.gvs-minimized,
|
||||||
|
.psv-map.gvs-map-minimized {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide navbar buttons */
|
||||||
|
.psv-container.gvs-small .psv-navbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
15
src/lib/img/expand.svg
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs7" />
|
||||||
|
<path
|
||||||
|
d="m 47.191182,9.8463618 h -7.807086 c -2.718629,0 -4.923181,-2.2044175 -4.923181,-4.9231809 C 34.460915,2.204012 36.665332,0 39.384096,0 H 59.076819 C 61.795447,0 64,2.204012 64,4.9231809 V 24.615904 c 0,2.718629 -2.204417,4.922641 -4.923181,4.922641 -2.719169,0 -4.923181,-2.204012 -4.923181,-4.922641 V 16.808818 L 40.404409,30.558047 c -1.922649,1.922649 -5.039807,1.922649 -6.962456,0 -1.922649,-1.922649 -1.922649,-5.039807 0,-6.962456 z M 16.808818,54.153638 h 7.807086 c 2.718629,0 4.922641,2.204012 4.922641,4.923181 C 29.538545,61.795447 27.334533,64 24.615904,64 H 4.9231809 C 2.204012,64 0,61.795583 0,59.076819 V 39.384096 c 0,-2.718629 2.204012,-4.923181 4.9231809,-4.923181 2.7186283,0 4.9231809,2.204417 4.9231809,4.923181 v 7.807086 L 23.595591,33.441953 c 1.922649,-1.922649 5.039807,-1.922649 6.962456,0 1.922649,1.922649 1.922649,5.039807 0,6.962456 z"
|
||||||
|
id="path7"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke-width:0.135141" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/lib/img/loader_0.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
src/lib/img/loader_1.jpg
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
src/lib/img/loader_base.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src/lib/img/loader_hd.jpg
Normal file
|
After Width: | Height: | Size: 276 KiB |
13
src/lib/img/map.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M 19.692758,56.197145 V 2.0126606 L 1.5632003,9.1193325 C 0.6204633,9.4909745 0,10.39927 0,11.410826 v 48.896935 c 0,0.814739 0.4008795,1.575569 1.0759311,2.033685 0.6718081,0.457401 1.5261764,0.556167 2.2836594,0.25794 z M 39.384118,7.8025185 24.614851,2.0128268 V 56.197312 l 14.769267,5.78969 z m 4.923589,0 V 61.987002 L 62.417305,54.888148 C 63.342495,54.533405 64,53.63745 64,52.588838 V 3.6919025 C 64,2.8771635 63.598471,2.116981 62.924068,1.6588821 62.25226,1.2008331 61.397891,1.1027165 60.639746,1.4002929 Z"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke-width:0.166328" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 834 B |
63
src/lib/img/marker.svg
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 12.7 12.7"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04, custom)"
|
||||||
|
sodipodi:docname="marker.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="false"
|
||||||
|
units="px"
|
||||||
|
width="32px"
|
||||||
|
inkscape:zoom="10.248373"
|
||||||
|
inkscape:cx="19.710447"
|
||||||
|
inkscape:cy="21.320458"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1009"
|
||||||
|
inkscape:window-x="1920"
|
||||||
|
inkscape:window-y="573"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs2" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Calque 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<path
|
||||||
|
style="fill:#bf360c;fill-opacity:1;stroke:#ffffff;stroke-width:0.661458;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path6342"
|
||||||
|
sodipodi:type="arc"
|
||||||
|
sodipodi:cx="-8.9855032"
|
||||||
|
sodipodi:cy="-0.0052228207"
|
||||||
|
sodipodi:rx="5.9781651"
|
||||||
|
sodipodi:ry="5.9781651"
|
||||||
|
sodipodi:start="0"
|
||||||
|
sodipodi:end="1.5707963"
|
||||||
|
sodipodi:arc-type="slice"
|
||||||
|
d="M -3.007338,-0.00522282 A 5.9781651,5.9781651 0 0 1 -8.9855032,5.9729423 v -5.97816512 z"
|
||||||
|
transform="matrix(-0.70710482,-0.70710874,0.70710482,-0.70710874,0,0)" />
|
||||||
|
<circle
|
||||||
|
style="fill:#ff6f00;fill-opacity:1;stroke:#ffffff;stroke-width:0.660027;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path846"
|
||||||
|
cx="6.3499999"
|
||||||
|
cy="6.5445719"
|
||||||
|
r="2.6401064" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
12
src/lib/img/minimize.svg
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="m 29.367321,38.633596 v 15.314723 c 0,2.209304 -1.790308,3.999734 -3.999777,3.999734 -2.209329,0 -3.999779,-1.790289 -3.999779,-3.999734 V 48.290257 L 6.829052,62.828813 C 6.048068,63.609235 5.0237031,64 4.0004875,64 2.9772861,64 1.9529213,63.609235 1.1719231,62.82826 c -1.56256411,-1.562547 -1.56256411,-4.094945 0,-5.656357 L 15.710636,42.633345 h -5.658121 c -2.2093289,0 -3.9997786,-1.790289 -3.9997786,-3.999734 0,-2.209447 1.7903077,-3.999736 3.9997786,-3.999736 h 15.314886 c 2.209328,-5.67e-4 4.000204,1.790856 4.000204,4.000161 z M 62.829349,1.1719104 c -1.562564,-1.5625472 -4.09499,-1.5625472 -5.656418,0 L 42.634216,15.710467 v -5.658061 c 0,-2.2093045 -1.790307,-3.9997349 -3.999776,-3.9997349 -2.209329,0 -3.99978,1.7902884 -3.99978,3.9997349 v 15.314722 c 0,2.209304 1.790308,3.999735 3.99978,3.999735 h 15.314887 c 2.209328,0 3.999777,-1.790288 3.999777,-3.999735 0,-2.209447 -1.790307,-3.999735 -3.999777,-3.999735 H 48.291204 L 62.829917,6.8288363 c 1.560294,-1.561412 1.560294,-4.093953 -0.0011,-5.6563583 z"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke-width:0.141897" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
13
src/lib/img/picture.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
version="1.1"
|
||||||
|
id="svg4"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
id="path2"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;stroke-width:0.168712"
|
||||||
|
d="m 61.68284,5.313288 -0.0014,6.748e-4 H 2.2374168 C 1.0386307,5.313946 0,6.2727926 0,7.5513628 V 56.449295 c 0,1.198786 0.95884664,2.237417 2.2374168,2.237417 H 61.762583 C 62.961369,58.686712 64,57.727866 64,56.449295 V 7.4703015 C 63.920256,6.2715155 62.961309,5.3132863 61.68284,5.313288 Z M 5.4330661,8.4301824 H 58.565944 c 1.198787,0 2.237416,0.9588466 2.237416,2.2374166 v 39.391054 l -0.08007,-0.0023 -11.425652,-19.015735 c -0.639268,-1.038628 -2.157025,-1.038628 -2.796277,0 L 37.392207,46.301178 22.69116,20.495097 c -0.639268,-1.118377 -2.157025,-1.118377 -2.796277,0 L 3.195651,49.738034 V 10.667599 c 0,-1.1987859 0.958845,-2.2374166 2.2374151,-2.2374166 z M 37.312794,12.744542 c -2.956348,0 -5.353325,2.396317 -5.353325,5.352664 0,2.957023 2.396977,5.353325 5.353325,5.353325 2.956347,0 5.353324,-2.396302 5.353324,-5.353325 0,-2.956516 -2.396977,-5.352664 -5.353324,-5.352664 z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
2
src/lib/index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import Viewer from './Viewer';
|
||||||
|
export default Viewer;
|
||||||
193
src/lib/tests/API.test.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import API from './../API';
|
||||||
|
|
||||||
|
const ENDPOINT = "http://360-dev.pavie.info";
|
||||||
|
const URL_OK = ENDPOINT + "/api/search";
|
||||||
|
const TILES_OK = ENDPOINT + "/api/map/{z}/{x}/{y}.mvt";
|
||||||
|
const THIRD_PARTY_URL = "http://my.custom.api/points/to/stac/search";
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('works with valid endpoint', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(api._endpoint).toBe(URL_OK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with relative path', () => {
|
||||||
|
const api = new API("/api/search");
|
||||||
|
expect(api._endpoint).toBe("http://localhost/api/search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails if endpoint is invalid', () => {
|
||||||
|
expect(() => new API("not an url")).toThrow("endpoint parameter is not a valid URL");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPicturesAroundCoordinatesUrl', () => {
|
||||||
|
it('works with valid coordinates', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(api.getPicturesAroundCoordinatesUrl(48.7, -1.25)).toBe(`${URL_OK}?bbox=[-1.2505,48.6995,-1.2495,48.7005]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails if coordinates are invalid', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(() => api.getPicturesAroundCoordinatesUrl()).toThrow("lat and lon parameters should be valid numbers");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPictureMetadataUrl', () => {
|
||||||
|
it('works with valid ID', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(api.getPictureMetadataUrl("whatever-id")).toBe(`${URL_OK}?ids=["whatever-id"]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails if picId is invalid', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(() => api.getPictureMetadataUrl()).toThrow("picId should be a valid picture unique identifier");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPicturesTilesUrl', () => {
|
||||||
|
it('works if URL is set in options', () => {
|
||||||
|
const url = "http://360-dev.pavie.info/custom/route/{z}/{x}/{y}";
|
||||||
|
const api = new API(URL_OK, url);
|
||||||
|
expect(api.getPicturesTilesUrl()).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works if endpoint is Geovisio', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(api.getPicturesTilesUrl()).toBe(TILES_OK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails with custom endpoint and no pictures URL set', () => {
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
expect(() => api.getPicturesTilesUrl()).toThrow("Pictures vector tiles URL is unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPictureThumbnailUrl', () => {
|
||||||
|
it('works with a geovisio instance', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
return api.getPictureThumbnailUrl("picId").then(url => {
|
||||||
|
expect(url).toBe(ENDPOINT + "/api/pictures/picId/thumb.jpg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with a third-party STAC API', () => {
|
||||||
|
// Mock API search
|
||||||
|
const thumbUrl = "http://my.custom.api/pic/thumb.jpg";
|
||||||
|
global.fetch = jest.fn(() => Promise.resolve({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
features: [ {
|
||||||
|
"assets": {
|
||||||
|
"thumb": {
|
||||||
|
"href": thumbUrl,
|
||||||
|
"roles": ["thumbnail"],
|
||||||
|
"type": "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
return api.getPictureThumbnailUrl("picId").then(url => {
|
||||||
|
expect(url).toBe(thumbUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with a third-party STAC API without thumbnail', () => {
|
||||||
|
// Mock API search
|
||||||
|
global.fetch = jest.fn(() => Promise.resolve({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
features: [ {
|
||||||
|
"assets": {}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
return api.getPictureThumbnailUrl("picId").then(url => {
|
||||||
|
expect(url).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPictureThumbnailForSequence', () => {
|
||||||
|
it('works with an existing sequence', () => {
|
||||||
|
const resPicId = "cbfc3add-8173-4464-98c8-de2a43c6a50f";
|
||||||
|
const thumbUrl = "http://my.custom.api/pic/thumb.jpg";
|
||||||
|
// Mock API search
|
||||||
|
global.fetch = jest.fn(() => Promise.resolve({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
features: [ {
|
||||||
|
"id": resPicId,
|
||||||
|
"assets": {
|
||||||
|
"thumb": {
|
||||||
|
"href": thumbUrl,
|
||||||
|
"roles": ["thumbnail"],
|
||||||
|
"type": "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
return api.getPictureThumbnailForSequence("208b981a-262e-4966-97b6-98ee0ceb8df0").then(picMeta => {
|
||||||
|
expect(picMeta.id).toBe(resPicId);
|
||||||
|
expect(Object.values(picMeta.assets).find(a => a.roles.includes("thumbnail")).href).toBe(thumbUrl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with no results', () => {
|
||||||
|
// Mock API search
|
||||||
|
global.fetch = jest.fn(() => Promise.resolve({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
features: []
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
return api.getPictureThumbnailForSequence("208b981a-262e-4966-97b6-98ee0ceb8df0").then(picMeta => {
|
||||||
|
expect(picMeta).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isGeoVisioInstance', () => {
|
||||||
|
it('works with Geovisio', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(api.isGeoVisioEndpoint()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with any other STAC API instance', () => {
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
expect(api.isGeoVisioEndpoint()).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getGeoVisioRoot', () => {
|
||||||
|
it('works with simple URL', () => {
|
||||||
|
const api = new API(URL_OK);
|
||||||
|
expect(api.getGeoVisioRoot()).toBe(ENDPOINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works with URL and added parameters', () => {
|
||||||
|
const api = new API(URL_OK+"?bla=bla");
|
||||||
|
expect(api.getGeoVisioRoot()).toBe(ENDPOINT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on not-Geovisio API', () => {
|
||||||
|
const api = new API(THIRD_PARTY_URL);
|
||||||
|
expect(() => api.getGeoVisioRoot()).toThrow("Can't get root endpoint on a third-party STAC API");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidHttpUrl', () => {
|
||||||
|
it('works with valid endpoint', () => {
|
||||||
|
expect(API.isValidHttpUrl(URL_OK)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails if endpoint is invalid', () => {
|
||||||
|
expect(API.isValidHttpUrl("not an url")).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
4
src/locales/fr.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"general": {},
|
||||||
|
"pages": {}
|
||||||
|
}
|
||||||
22
src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import fr from './locales/fr.json'
|
||||||
|
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
locale: 'fr',
|
||||||
|
globalInjection: true,
|
||||||
|
legacy: false,
|
||||||
|
messages: {
|
||||||
|
fr
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(i18n)
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
15
src/router/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import HomeView from "../views/HomeView.vue";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "home",
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
BIN
src/views/.DS_Store
vendored
Normal file
23
src/views/HomeView.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div id="viewer"></div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import Viewer from "../lib/Viewer.js";
|
||||||
|
const onloadImg = () => {
|
||||||
|
new Viewer(
|
||||||
|
"viewer",
|
||||||
|
"http://dev.geovisio.xuan.codeursenliberte.fr/api/search",
|
||||||
|
{
|
||||||
|
map: {
|
||||||
|
startWide: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onloadImg();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped></style>
|
||||||
12
tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tsconfig.config.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
14
tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.config.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
tsconfig.vitest.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import eslintPlugin from "vite-plugin-eslint";
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
base: "/",
|
||||||
|
plugins: [vue(), eslintPlugin()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["src/lib/Map.js"],
|
||||||
|
},
|
||||||
|
});
|
||||||