Skip to content

Commit 1c6e36c

Browse files
committed
ObliqueView: initial implementation of oblique aerial view plugin
1 parent 478638f commit 1c6e36c

File tree

7 files changed

+481
-0
lines changed

7 files changed

+481
-0
lines changed

appConfig.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import MapTipPlugin from './plugins/MapTip';
4242
import MeasurePlugin from './plugins/Measure';
4343
import NewsPopupPlugin from './plugins/NewsPopup';
4444
import ObjectListPlugin from './plugins/ObjectList';
45+
import ObliqueViewPlugin from './plugins/ObliqueView';
4546
import OverviewMapPlugin from './plugins/OverviewMap';
4647
import PanoramaxPlugin from './plugins/Panoramax';
4748
import PortalPlugin from './plugins/Portal';
@@ -112,6 +113,7 @@ export default {
112113
MeasurePlugin: MeasurePlugin,
113114
NewsPopupPlugin: NewsPopupPlugin,
114115
ObjectListPlugin: ObjectListPlugin(/* CustomEditingInterface */),
116+
ObliqueViewPlugin: ObliqueViewPlugin,
115117
OverviewMapPlugin: OverviewMapPlugin,
116118
PanoramaxPlugin: PanoramaxPlugin,
117119
PortalPlugin: PortalPlugin,

doc/plugins.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ Plugin reference
3131
* [Measure](#measure)
3232
* [NewsPopup](#newspopup)
3333
* [ObjectList](#objectlist)
34+
* [ObliqueView](#obliqueview)
3435
* [OverviewMap](#overviewmap)
3536
* [Panoramax](#panoramax)
3637
* [Portal](#portal)
@@ -766,6 +767,16 @@ This plugin queries the dataset via the editing service specified by
766767
| showLimitToExtent | `bool` | Whether to show the "Limit to extent" checkbox | `true` |
767768
| zoomLevel | `number` | The zoom level for zooming to point features. | `1000` |
768769

770+
ObliqueView<a name="obliqueview"></a>
771+
----------------------------------------------------------------
772+
Display oblique satellite imagery.
773+
774+
| Property | Type | Description | Default value |
775+
|----------|------|-------------|---------------|
776+
| geometry | `{`<br />`  initialWidth: number,`<br />`  initialHeight: number,`<br />`  initialX: number,`<br />`  initialY: number,`<br />`  initiallyDocked: bool,`<br />`  side: string,`<br />`}` | Default window geometry with size, position and docking status. Positive position values (including '0') are related to top (InitialY) and left (InitialX), negative values (including '-0') to bottom (InitialY) and right (InitialX). | `{`<br />`  initialWidth: 480,`<br />`  initialHeight: 640,`<br />`  initialX: 0,`<br />`  initialY: 0,`<br />`  initiallyDocked: true,`<br />`  side: 'left'`<br />`}` |
777+
| initialScale | `number` | The initial map scale. | `1000` |
778+
| scales | `[number]` | A list of allowed map scales, in decreasing order. | `[20000, 10000, 5000, 2500, 1000, 500, 250]` |
779+
769780
OverviewMap<a name="overviewmap"></a>
770781
----------------------------------------------------------------
771782
Overview map support for the map component.

icons/oblique.svg

Lines changed: 53 additions & 0 deletions
Loading

plugins/ObliqueView.jsx

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
/**
2+
* Copyright 2025 Sourcepole AG
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React from 'react';
10+
import {connect} from 'react-redux';
11+
12+
import axios from 'axios';
13+
import ol from 'openlayers';
14+
import PropTypes from 'prop-types';
15+
16+
import {setCurrentTask} from '../actions/task';
17+
import Icon from '../components/Icon';
18+
import ResizeableWindow from '../components/ResizeableWindow';
19+
import LayerRegistry from '../components/map/layers/index';
20+
import InputContainer from '../components/widgets/InputContainer';
21+
import ConfigUtils from '../utils/ConfigUtils';
22+
import LayerUtils from '../utils/LayerUtils';
23+
import LocaleUtils from '../utils/LocaleUtils';
24+
import MapUtils from '../utils/MapUtils';
25+
import MiscUtils from '../utils/MiscUtils';
26+
27+
import './style/ObliqueView.css';
28+
29+
30+
/**
31+
* Display oblique satellite imagery.
32+
*/
33+
class ObliqueView extends React.Component {
34+
static propTypes = {
35+
active: PropTypes.bool,
36+
/** Default window geometry with size, position and docking status. Positive position values (including '0') are related to top (InitialY) and left (InitialX), negative values (including '-0') to bottom (InitialY) and right (InitialX). */
37+
geometry: PropTypes.shape({
38+
initialWidth: PropTypes.number,
39+
initialHeight: PropTypes.number,
40+
initialX: PropTypes.number,
41+
initialY: PropTypes.number,
42+
initiallyDocked: PropTypes.bool,
43+
side: PropTypes.string
44+
}),
45+
/** The initial map scale. */
46+
initialScale: PropTypes.number,
47+
mapBbox: PropTypes.object,
48+
projection: PropTypes.string,
49+
/** A list of allowed map scales, in decreasing order. */
50+
scales: PropTypes.arrayOf(PropTypes.number),
51+
setCurrentTask: PropTypes.func,
52+
theme: PropTypes.object,
53+
themes: PropTypes.object
54+
};
55+
static defaultProps = {
56+
geometry: {
57+
initialWidth: 480,
58+
initialHeight: 640,
59+
initialX: 0,
60+
initialY: 0,
61+
initiallyDocked: true,
62+
side: 'left'
63+
},
64+
initialScale: 1000,
65+
scales: [20000, 10000, 5000, 2500, 1000, 500, 250]
66+
};
67+
static defaultState = {
68+
active: false,
69+
selectedDataset: null,
70+
datasetConfig: null,
71+
currentDirection: null,
72+
currentZoom: 0
73+
};
74+
constructor(props) {
75+
super(props);
76+
const controls = ol.control.defaults({
77+
zoom: false,
78+
attribution: false,
79+
rotate: false
80+
});
81+
const interactions = ol.interaction.defaults({
82+
onFocusOnly: false
83+
});
84+
this.map = new ol.Map({controls, interactions});
85+
this.map.on('moveend', this.searchClosestImage);
86+
this.map.on('rotateend', this.searchClosestImage);
87+
this.closestImage = null;
88+
this.obliqueImageryLayer = null;
89+
this.state = ObliqueView.defaultState;
90+
}
91+
componentDidUpdate(prevProps, prevState) {
92+
if (this.props.active && !prevProps.active) {
93+
this.setState({active: true});
94+
this.props.setCurrentTask(null);
95+
}
96+
if (
97+
this.props.active && this.props.theme &&
98+
(this.props.theme !== prevProps.theme) || (this.props.active && !prevProps.active)
99+
) {
100+
const datasets = this.props.theme.obliqueDatasets || [];
101+
const defaultDataset = datasets.find(entry => entry.default)?.name ?? datasets[0]?.name;
102+
this.setState({selectedDataset: defaultDataset, datasetConfig: null, currentDirection: null});
103+
}
104+
if (this.state.selectedDataset !== prevState.selectedDataset) {
105+
this.closestImage = null;
106+
this.obliqueImageryLayer = null;
107+
this.queryDatasetConfig();
108+
}
109+
if (this.state.datasetConfig && this.state.datasetConfig !== prevState.datasetConfig) {
110+
this.setupLayer();
111+
}
112+
if (this.state.datasetConfig && this.state.currentDirection !== prevState.currentDirection) {
113+
this.obliqueImageryLayer?.getSource?.()?.refresh?.();
114+
this.map.getView()?.setRotation?.(this.getRotation() / 180 * Math.PI);
115+
}
116+
if (this.state.datasetConfig && this.state.currentZoom !== prevState.currentZoom) {
117+
this.map.getView()?.setZoom?.(this.state.currentZoom);
118+
}
119+
}
120+
onClose = () => {
121+
this.setState(ObliqueView.defaultState);
122+
};
123+
render() {
124+
if (!this.state.active) {
125+
return null;
126+
}
127+
const rot = this.getRotation();
128+
return (
129+
<ResizeableWindow dockable={this.props.geometry.side} icon="oblique"
130+
initialHeight={this.props.geometry.initialHeight} initialWidth={this.props.geometry.initialWidth}
131+
initialX={this.props.geometry.initialX} initialY={this.props.geometry.initialY}
132+
initiallyDocked={this.props.geometry.initiallyDocked}
133+
onClose={this.onClose} splitScreenWhenDocked splitTopAndBottomBar title={LocaleUtils.tr("obliqueview.title")}
134+
>
135+
<div className="obliqueview-body">
136+
<div className="obliqueview-map" ref={el => this.map.setTarget(el)} tabIndex={0} />
137+
{!this.state.selectedDataset && (
138+
<div className="obliqueview-empty-overlay">
139+
{LocaleUtils.tr("obliqueview.nodataset")}
140+
</div>
141+
)}
142+
<div className="obliqueview-nav-rotate" style={{transform: `rotate(${rot}deg)`}}>
143+
<span />
144+
<span className="obliqueview-nav-dir" onClick={() => this.setState({currentDirection: "n"})} onKeyDown={MiscUtils.checkKeyActivate} style={{transform: `rotate(${-rot}deg)`}} tabIndex={0}>N</span>
145+
<span />
146+
<span className="obliqueview-nav-dir" onClick={() => this.setState({currentDirection: "w"})} onKeyDown={MiscUtils.checkKeyActivate} style={{transform: `rotate(${-rot}deg)`}} tabIndex={0}>W</span>
147+
<span />
148+
<span className="obliqueview-nav-dir" onClick={() => this.setState({currentDirection: "e"})} onKeyDown={MiscUtils.checkKeyActivate} style={{transform: `rotate(${-rot}deg)`}} tabIndex={0}>E</span>
149+
<span />
150+
<span className="obliqueview-nav-dir" onClick={() => this.setState({currentDirection: "s"})} onKeyDown={MiscUtils.checkKeyActivate} style={{transform: `rotate(${-rot}deg)`}} tabIndex={0}>S</span>
151+
<span />
152+
</div>
153+
<div className="obliqueview-nav-zoom">
154+
<Icon icon="plus" onClick={() => this.changeZoom(+1)} />
155+
<Icon icon="minus" onClick={() => this.changeZoom(-1)} />
156+
</div>
157+
<div className="obliqueview-bottombar">
158+
{this.renderScaleChooser()}
159+
</div>
160+
</div>
161+
</ResizeableWindow>
162+
);
163+
}
164+
renderScaleChooser = () => {
165+
return (
166+
<div className="obliqueview-scalechooser">
167+
<span>{LocaleUtils.tr("bottombar.scale_label")}:&nbsp;</span>
168+
<InputContainer>
169+
<span role="prefix"> 1 : </span>
170+
<select onChange={ev => this.setState({currentZoom: parseInt(ev.target.value, 10)})} role="input" value={this.state.currentZoom}>
171+
{this.props.scales.map((item, index) =>
172+
(<option key={index} value={index}>{LocaleUtils.toLocaleFixed(item, 0)}</option>)
173+
)}
174+
</select>
175+
</InputContainer>
176+
</div>
177+
);
178+
};
179+
changeZoom = (delta) => {
180+
this.setState(state => ({currentZoom: Math.max(0, Math.min(state.currentZoom + delta, this.props.scales.length - 1))}));
181+
};
182+
queryDatasetConfig = () => {
183+
const obliqueImageryServiceUrl = ConfigUtils.getConfigProp('obliqueImageryServiceUrl');
184+
if (this.state.selectedDataset && obliqueImageryServiceUrl) {
185+
const reqUrl = obliqueImageryServiceUrl.replace(/\/$/, '') + `/${this.state.selectedDataset}/config`;
186+
axios.get(reqUrl).then(response => {
187+
const datasetConfig = response.data;
188+
const direction = 'n' in datasetConfig.image_centers ? 'n' : Object.keys(datasetConfig.image_centers)[0];
189+
this.setState({datasetConfig: datasetConfig, currentDirection: direction});
190+
}).catch(() => {
191+
/* eslint-disable-next-line */
192+
console.warn("Failed to load dataset config");
193+
});
194+
}
195+
};
196+
setupLayer = () => {
197+
const datasetConfig = this.state.datasetConfig;
198+
199+
const projection = new ol.proj.Projection({
200+
code: datasetConfig.crs,
201+
extent: datasetConfig.extent,
202+
units: "m"
203+
});
204+
const targetScale = this.props.initialScale;
205+
const zoom = this.props.scales.reduce((best, v, i) =>
206+
Math.abs(v - targetScale) < Math.abs(this.props.scales[best] - targetScale) ? i : best, 0
207+
);
208+
this.map.setView(new ol.View({
209+
projection: projection,
210+
// extent: datasetConfig.extent,
211+
center: ol.extent.getCenter(datasetConfig.extent),
212+
rotation: this.getRotation() / 180 * Math.PI,
213+
zoom: zoom,
214+
resolutions: MapUtils.getResolutionsForScales(this.props.scales, datasetConfig.crs),
215+
constrainResolution: true
216+
// showFullExtent: true
217+
}));
218+
this.setState({currentZoom: zoom});
219+
this.map.on('moveend', () => {
220+
this.setState(state => {
221+
const newZoom = this.map.getView().getZoom();
222+
if (newZoom !== state.currentZoom) {
223+
return {currentZoom: newZoom};
224+
}
225+
return null;
226+
});
227+
});
228+
229+
const layers = [];
230+
231+
const themeConfig = this.props.theme.obliqueDatasets.find(entry => entry.name === this.state.selectedDataset);
232+
if (themeConfig.backgroundLayer) {
233+
const layerConfig = LayerUtils.splitLayerUrlParam(themeConfig.backgroundLayer);
234+
layerConfig.version = this.props.themes.defaultWMSVersion || "1.3.0";
235+
layerConfig.opacity = themeConfig.backgroundOpacity ?? 127;
236+
const layerCreator = LayerRegistry[layerConfig.type];
237+
if (layerCreator) {
238+
layers.push(layerCreator.create(layerConfig, this.map));
239+
}
240+
}
241+
242+
this.obliqueImageryLayer = new ol.layer.Tile({
243+
source: new ol.source.XYZ({
244+
projection: projection,
245+
tileGrid: new ol.tilegrid.TileGrid({
246+
extent: datasetConfig.extent,
247+
resolutions: datasetConfig.resolutions,
248+
tileSize: datasetConfig.tileSize,
249+
origin: datasetConfig.origin
250+
}),
251+
url: datasetConfig.url,
252+
crossOrigin: "anonymous",
253+
tileLoadFunction: (tile, src) => {
254+
if ((this.closestImage ?? null) !== null) {
255+
src += "?img=" + this.closestImage;
256+
}
257+
tile.getImage().src = src.replace('{direction}', this.state.currentDirection);
258+
}
259+
})
260+
});
261+
layers.push(this.obliqueImageryLayer);
262+
this.map.setLayers(layers);
263+
};
264+
searchClosestImage = () => {
265+
let best = null;
266+
const imageCenters = this.state.datasetConfig?.image_centers?.[this.state.currentDirection];
267+
if (imageCenters) {
268+
const center = this.map.getView().getCenter();
269+
const dsqr = (p, q) => (p[0] - q[0]) * (p[0] - q[0]) + (p[1] - q[1]) * (p[1] - q[1]);
270+
best = 0;
271+
let bestDist = dsqr(center, imageCenters[0]);
272+
for (let i = 1; i < imageCenters.length; ++i) {
273+
const dist = dsqr(center, imageCenters[i]);
274+
if (dist < bestDist) {
275+
bestDist = dist;
276+
best = i;
277+
}
278+
}
279+
}
280+
if (best !== this.closestImage) {
281+
if (this.obliqueImageryLayer) {
282+
this.obliqueImageryLayer.getSource().refresh();
283+
}
284+
this.closestImage = best;
285+
}
286+
};
287+
getRotation = () => {
288+
return {n: 0, w: 90, e: -90, s: 180}[this.state.currentDirection];
289+
};
290+
}
291+
292+
293+
export default connect((state) => ({
294+
active: state.task.id === "ObliqueView",
295+
mapBbox: state.map.bbox,
296+
theme: state.theme.current,
297+
themes: state.theme.themes
298+
}), {
299+
setCurrentTask: setCurrentTask
300+
})(ObliqueView);

0 commit comments

Comments
 (0)