Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Panoramax plugin #348

Merged
merged 5 commits into from
Mar 6, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions doc/plugins.md
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ Plugin reference
* [MapTip](#maptip)
* [Measure](#measure)
* [NewsPopup](#newspopup)
* [Panoramax](#panoramax)
* [Portal](#portal)
* [Print](#print)
* [ProcessNotifications](#processnotifications)
@@ -658,6 +659,17 @@ revision is published (specified via newsRev prop).
| showInSidebar | `bool` | Whether to show the news in a sidebar instead of a popup. | `undefined` |
| side | `string` | The side of the application on which to display the sidebar. | `undefined` |

Panoramax<a name="panoramax"></a>
----------------------------------------------------------------
Panoramax Integration for QWC2.

| Property | Type | Description | Default value |
|----------|------|-------------|---------------|
| loadSequencesTiles | `bool` | Whether or not to load the layer containing the image sequences. | `true` |
| panoramaxInstance | `string` | URL of the Panoramax instance. | `'api.panoramax.xyz'` |
| tileMode | `string` | Mode for the image sequences layer: either WMS (Require a custom URL) or MVT(EPSG:3857 only). | `'mvt'` |
| wmsUrl | `string` | URL of the WMS image sequences layer. | `undefined` |

Portal<a name="portal"></a>
----------------------------------------------------------------
Displays a landing lage, consisting of a full-screen theme switcher and a configurable menu.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@
"@loaders.gl/core": "^4.3.3",
"@loaders.gl/shapefile": "^4.3.3",
"@loaders.gl/zip": "^4.3.3",
"@panoramax/web-viewer": "^3.2.3",
"@reduxjs/toolkit": "^2.4.0",
"@turf/buffer": "^6.5.0",
"@turf/helpers": "^6.5.0",
266 changes: 266 additions & 0 deletions plugins/Panoramax.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
/**
* Copyright 2025 Sourcepole AG
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import {connect} from 'react-redux';

import * as PanoViewer from '@panoramax/web-viewer';
import axios from 'axios';
import PropTypes from 'prop-types';
import ConfigUtils from 'qwc2/utils/ConfigUtils';
import CoordinatesUtils from 'qwc2/utils/CoordinatesUtils';
import MapUtils from 'qwc2/utils/MapUtils';
import ResourceRegistry from 'qwc2/utils/ResourceRegistry';

import {addLayer, addLayerFeatures, removeLayer, LayerRole} from '../actions/layers';
import {setCurrentTask} from '../actions/task';
import MapSelection from '../components/MapSelection';
import ResizeableWindow from '../components/ResizeableWindow';
import LocaleUtils from '../utils/LocaleUtils';

import './style/Panoramax.css';
import '@panoramax/web-viewer/build/index.css';


/**
* Panoramax Integration for QWC2.
*
*/
class Panoramax extends React.Component {
static propTypes = {
active: PropTypes.bool,
addLayer: PropTypes.func,
addLayerFeatures: PropTypes.func,
geometry: PropTypes.shape({
initialWidth: PropTypes.number,
initialHeight: PropTypes.number,
initialX: PropTypes.number,
initialY: PropTypes.number,
initiallyDocked: PropTypes.bool,
side: PropTypes.string
}),
/** Whether or not to load the layer containing the image sequences. */
loadSequencesTiles: PropTypes.bool,
/** URL of the Panoramax instance. */
panoramaxInstance: PropTypes.string,
removeLayer: PropTypes.func,
setCurrentTask: PropTypes.func,
theme: PropTypes.object,
/** Mode for the image sequences layer: either WMS (Require a custom URL) or MVT(EPSG:3857 only). */
tileMode: PropTypes.string,
/** URL of the WMS image sequences layer. */
wmsUrl: PropTypes.string
};
static defaultProps = {
geometry: {
initialWidth: 640,
initialHeight: 640,
initialX: 0,
initialY: 0,
initiallyDocked: false,
side: 'left'
},
loadSequencesTiles: true,
panoramaxInstance: 'api.panoramax.xyz',
tileMode: 'mvt'
};
state = {
lon: null,
lat: null,
queryImage: null,
selectionActive: false,
selectionGeom: null,
yaw: null,
currentTooltip: ''
};
constructor(props) {
super(props);
this.viewerRef = React.createRef();
}
componentDidUpdate(prevProps, prevState) {
if (!prevProps.active && this.props.active) {
this.setState({selectionActive: true});
if (this.props.loadSequencesTiles) {
if (this.props.tileMode === "wms" && this.props.wmsUrl) {
this.addRecordingsWMS();
} else {
this.addRecordingsMVT();
}
}
} else if ( this.state.selectionGeom &&
this.state.selectionGeom !== prevState.selectionGeom) {
this.queryPoint(this.state.selectionGeom);
} else if (this.state.queryImage && !this.viewer && this.state.selectionGeom) {
this.initializeViewer(this.state.queryImage);
} else if (this.viewer && this.state.queryImage !== prevState.queryImage) {
this.viewer.select(null, this.state.queryImage, true);
}
}
componentWillUnmount() {
this.onClose();
}
onClose = () => {
this.props.setCurrentTask(null);
this.props.removeLayer('panoramax-recordings');
this.props.removeLayer('panoramaxselection');
this.setState({selectionGeom: null, queryImage: null, lon: null, lat: null, selectionActive: null, yaw: null, currentTooltip: ''});
if (this.viewer) {
this.viewer.stopSequence();
this.viewer.destroy();
delete this.viewer;
}
ResourceRegistry.removeResource('selected');
};
render() {
if (!this.props.active) {
return null;
}
const { selectionGeom, queryImage } = this.state;
return (
<>
{selectionGeom && (
<ResizeableWindow
dockable={this.props.geometry.side}
icon="Panoramax"
initialHeight={this.props.geometry.initialHeight}
initialWidth={this.props.geometry.initialWidth}
initialX={this.props.geometry.initialX}
initialY={this.props.geometry.initialY}
initiallyDocked={this.props.geometry.initiallyDocked}
onClose={this.onClose}
splitScreenWhenDocked
title={LocaleUtils.tr("panoramax.title")}
>
<div className="panoramax-body" role="body">
{!queryImage && !this.viewer ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', textAlign: 'center' }}>
<p>{LocaleUtils.tr("panoramax.notfound")}</p>
</div>
) : (
<div id="viewer" ref={this.viewerRef} style={{ height: '100%' }} />
)}
</div>
</ResizeableWindow>
)}
<MapSelection
active={this.state.selectionActive}
cursor={`url("${ConfigUtils.getAssetsPath()}/img/target.svg") 1 1, default`}
geomType={'Point'}
geometry={selectionGeom}
geometryChanged={(geom) => this.setState({ selectionGeom: geom })}
styleOptions={{ fillColor: [0, 0, 0, 0], strokeColor: [0, 0, 0, 0] }}
/>
</>
);
}
initializeViewer = (image) => {
const viewerElement = this.viewerRef.current;
if (viewerElement) {
this.viewer = new PanoViewer.Viewer(
viewerElement,
`https://${this.props.panoramaxInstance}/api`,
{
map: false,
selectedPicture: image
}
);
this.viewer.addEventListener('psv:picture-loading', (event) => {
this.setState(
{
lon: event.detail.lon,
lat: event.detail.lat
},
() => this.handlePanoramaxEvent()
);
});
this.viewer.addEventListener('psv:view-rotated', (event) => {
this.setState(
{ yaw: event.detail.x },
() => this.handlePanoramaxEvent()
);
});
this.viewer.addEventListener('psv:picture-loaded', (event) => {
this.setState(
{ yaw: event.detail.x },
() => this.handlePanoramaxEvent()
);
});
}
};
handlePanoramaxEvent = () => {
ResourceRegistry.addResource('selected', `${ConfigUtils.getAssetsPath()}/img/panoramax-cursor.svg`);
const layer = {
id: "panoramaxselection",
role: LayerRole.SELECTION
};
const feature = {
geometry: {
type: 'Point',
coordinates: [this.state.lon, this.state.lat]
},
crs: 'EPSG:4326',
styleName: 'image',
styleOptions: {
img: 'selected',
rotation: MapUtils.degreesToRadians(this.state.yaw),
anchor: [0.5, 0.5]
}
};
this.props.addLayerFeatures(layer, [feature], true);
};
addRecordingsMVT = () => {
const resolutions = MapUtils.getResolutionsForScales(this.props.theme.scales, this.props.theme.mapCrs);
const layer = {
id: 'panoramax-recordings',
type: 'mvt',
projection: this.props.theme.mapCrs,
tileGridConfig: {
origin: [0, 0],
resolutions: resolutions
},
style: `https://${this.props.panoramaxInstance}/api/map/style.json`,
role: LayerRole.USERLAYER
};
this.props.addLayer(layer);
};
addRecordingsWMS = () => {
const layer = {
id: 'panoramax-recordings',
type: 'wms',
projection: this.props.theme.mapCrs,
url: this.props.wmsUrl,
role: LayerRole.USERLAYER
};
this.props.addLayer(layer);
};
queryPoint = (props) => {
const [centerX, centerY] = CoordinatesUtils.reproject(props.coordinates, this.props.theme.mapCrs, 'EPSG:4326');
const offset = 0.001;
const bbox = `${centerX - offset},${centerY - offset},${centerX + offset},${centerY + offset}`;
axios.get(`https://${this.props.panoramaxInstance}/api/search?bbox=${bbox}`)
.then(response => {

this.setState({ queryImage: response.data.features[0].id });
})
.catch(() => {
this.setState({ queryImage: null });
});
};
}

export default connect((state) => ({
active: state.task.id === "Panoramax",
click: state.map.click,
mapScale: MapUtils.computeForZoom(state.map.scales, state.map.zoom),
theme: state.theme.current
}), {
addLayer: addLayer,
addLayerFeatures: addLayerFeatures,
removeLayer: removeLayer,
setCurrentTask: setCurrentTask
})(Panoramax);
15 changes: 15 additions & 0 deletions plugins/style/Panoramax.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
div.panoramax-body {
height: calc(100% + 0.5em);
position: relative;
display: flex;
flex-direction: column;
margin: -0.25em;
}

div.panoramax-body .gvs-psv-tour-arrows{
all: unset;
}

div.panoramax-body .gvs-btn:focus{
outline: none;
}
5 changes: 5 additions & 0 deletions translations/ca-ES.json
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"MeasureLineString": "Mesurar una línia",
"MeasurePolygon": "Mesurar un polígon",
"NewsPopup": "",
"Panoramax": "",
"Portal": "",
"PrintScreen3D": "",
"Redlining": "Línia de demarcació",
@@ -394,6 +395,10 @@
"width": "",
"windowtitle": ""
},
"panoramax": {
"notfound": "",
"title": ""
},
"pickfeature": {
"querying": ""
},
5 changes: 5 additions & 0 deletions translations/cs-CZ.json
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"MeasureLineString": "Změřit úsek",
"MeasurePolygon": "Měřit mnohoúhelník",
"NewsPopup": "",
"Panoramax": "",
"Portal": "",
"PrintScreen3D": "",
"Redlining": "Připomínkování",
@@ -394,6 +395,10 @@
"width": "",
"windowtitle": ""
},
"panoramax": {
"notfound": "",
"title": ""
},
"pickfeature": {
"querying": ""
},
5 changes: 5 additions & 0 deletions translations/de-CH.json
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"MeasureLineString": "Messen Linie",
"MeasurePolygon": "Messen Polygon",
"NewsPopup": "Aktuelles",
"Panoramax": "",
"Portal": "Portal",
"PrintScreen3D": "Raster Export",
"Redlining": "Zeichnen",
@@ -394,6 +395,10 @@
"width": "Breite",
"windowtitle": "Numerische Eingabe"
},
"panoramax": {
"notfound": "",
"title": ""
},
"pickfeature": {
"querying": "Abfragen..."
},
5 changes: 5 additions & 0 deletions translations/de-DE.json
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"MeasureLineString": "Messen Linie",
"MeasurePolygon": "Messen Polygon",
"NewsPopup": "Aktuelles",
"Panoramax": "",
"Portal": "Portal",
"PrintScreen3D": "Raster Export",
"Redlining": "Zeichnen",
@@ -394,6 +395,10 @@
"width": "Breite",
"windowtitle": "Numerische Eingabe"
},
"panoramax": {
"notfound": "",
"title": ""
},
"pickfeature": {
"querying": "Abfragen..."
},
Loading