OpenDroneMap-WebODM/app/static/app/js/components/Map.jsx

616 wiersze
21 KiB
React
Czysty Zwykły widok Historia

import React from 'react';
import ReactDOM from 'ReactDOM';
import '../css/Map.scss';
import 'leaflet/dist/leaflet.css';
import Leaflet from 'leaflet';
2016-11-26 15:27:54 +00:00
import async from 'async';
import '../vendor/leaflet/L.Control.MousePosition.css';
import '../vendor/leaflet/L.Control.MousePosition';
import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css';
import '../vendor/leaflet/Leaflet.Autolayers/leaflet-autolayers';
2019-12-10 21:00:56 +00:00
// import '../vendor/leaflet/L.TileLayer.NoGap';
import Dropzone from '../vendor/dropzone';
import $ from 'jquery';
import ErrorMessage from './ErrorMessage';
import ImagePopup from './ImagePopup';
2021-09-24 20:54:21 +00:00
import GCPPopup from './GCPPopup';
import SwitchModeButton from './SwitchModeButton';
2017-12-01 18:52:36 +00:00
import ShareButton from './ShareButton';
import AssetDownloads from '../classes/AssetDownloads';
import {addTempLayer} from '../classes/TempLayer';
import PropTypes from 'prop-types';
2018-02-09 17:38:42 +00:00
import PluginsAPI from '../classes/plugins/API';
2018-09-04 23:06:04 +00:00
import Basemaps from '../classes/Basemaps';
import Standby from './Standby';
import LayersControl from './LayersControl';
import update from 'immutability-helper';
2019-11-19 21:27:42 +00:00
import Utils from '../classes/Utils';
import '../vendor/leaflet/Leaflet.Ajax';
import '../vendor/leaflet/Leaflet.Awesome-markers';
2020-12-16 19:37:35 +00:00
import { _ } from '../classes/gettext';
class Map extends React.Component {
static defaultProps = {
showBackground: false,
mapType: "orthophoto",
2021-05-26 14:55:53 +00:00
public: false,
shareButtons: true
};
static propTypes = {
showBackground: PropTypes.bool,
tiles: PropTypes.array.isRequired,
mapType: PropTypes.oneOf(['orthophoto', 'plant', 'dsm', 'dtm']),
2021-05-26 14:55:53 +00:00
public: PropTypes.bool,
shareButtons: PropTypes.bool
};
constructor(props) {
super(props);
this.state = {
error: "",
singleTask: null, // When this is set to a task, show a switch mode button to view the 3d model
pluginActionButtons: [],
2020-12-16 19:45:15 +00:00
showLoading: false, // for drag&drop of files and first load
2019-11-14 17:16:01 +00:00
opacity: 100,
imageryLayers: [],
overlays: []
};
this.basemaps = {};
this.mapBounds = null;
this.autolayers = null;
this.addedCameraShots = false;
this.loadImageryLayers = this.loadImageryLayers.bind(this);
this.updatePopupFor = this.updatePopupFor.bind(this);
this.handleMapMouseDown = this.handleMapMouseDown.bind(this);
}
2019-11-07 18:27:34 +00:00
updateOpacity = (evt) => {
this.setState({
opacity: parseFloat(evt.target.value),
});
}
updatePopupFor(layer){
const popup = layer.getPopup();
$('#layerOpacity', popup.getContent()).val(layer.options.opacity);
}
typeToHuman = (type) => {
switch(type){
case "orthophoto":
2020-12-16 19:37:35 +00:00
return _("Orthophoto");
case "plant":
2020-12-16 19:37:35 +00:00
return _("Plant Health");
case "dsm":
2020-12-16 19:37:35 +00:00
return _("DSM");
case "dtm":
2020-12-16 19:37:35 +00:00
return _("DTM");
}
return "";
}
loadImageryLayers(forceAddLayers = false){
// Cancel previous requests
if (this.tileJsonRequests) {
this.tileJsonRequests.forEach(tileJsonRequest => tileJsonRequest.abort());
this.tileJsonRequests = [];
}
const { tiles } = this.props,
layerId = layer => {
const meta = layer[Symbol.for("meta")];
2017-12-01 18:52:36 +00:00
return meta.task.project + "_" + meta.task.id;
};
// Remove all previous imagery layers
// and keep track of which ones were selected
const prevSelectedLayers = [];
2019-11-14 17:16:01 +00:00
this.state.imageryLayers.forEach(layer => {
if (this.map.hasLayer(layer)) prevSelectedLayers.push(layerId(layer));
layer.remove();
});
2019-11-14 17:16:01 +00:00
this.setState({imageryLayers: []});
// Request new tiles
return new Promise((resolve, reject) => {
this.tileJsonRequests = [];
async.each(tiles, (tile, done) => {
2019-11-14 21:57:49 +00:00
const { url, meta, type } = tile;
let metaUrl = url + "metadata";
2019-11-27 16:41:17 +00:00
if (type == "plant") metaUrl += "?formula=NDVI&bands=RGN&color_map=rdylgn";
2021-10-19 16:56:27 +00:00
if (type == "dsm" || type == "dtm") metaUrl += "?hillshade=6&color_map=viridis";
2019-11-19 21:27:42 +00:00
2019-11-14 21:57:49 +00:00
this.tileJsonRequests.push($.getJSON(metaUrl)
2019-11-07 18:27:34 +00:00
.done(mres => {
2019-11-19 21:27:42 +00:00
const { scheme, name, maxzoom, statistics } = mres;
2019-11-07 18:27:34 +00:00
const bounds = Leaflet.latLngBounds(
2019-11-07 18:27:34 +00:00
[mres.bounds.value.slice(0, 2).reverse(), mres.bounds.value.slice(2, 4).reverse()]
);
2019-11-19 21:27:42 +00:00
// Build URL
let tileUrl = mres.tiles[0];
2022-07-08 18:36:49 +00:00
const TILESIZE = 512;
2020-01-22 03:08:40 +00:00
// Set rescale
if (statistics){
2019-11-19 21:27:42 +00:00
const params = Utils.queryParams({search: tileUrl.slice(tileUrl.indexOf("?"))});
if (statistics["1"]){
// Add rescale
params["rescale"] = encodeURIComponent(`${statistics["1"]["min"]},${statistics["1"]["max"]}`);
}else{
console.warn("Cannot find min/max statistics for dataset, setting to -1,1");
params["rescale"] = encodeURIComponent("-1,1");
}
2022-07-08 18:36:49 +00:00
params["size"] = TILESIZE;
2019-12-02 17:57:16 +00:00
tileUrl = Utils.buildUrlWithQuery(tileUrl, params);
2022-07-08 18:36:49 +00:00
}else{
tileUrl = Utils.buildUrlWithQuery(tileUrl, { size: TILESIZE });
2019-11-19 21:27:42 +00:00
}
const layer = Leaflet.tileLayer(tileUrl, {
bounds,
2019-11-05 20:47:29 +00:00
minZoom: 0,
2019-11-18 23:33:58 +00:00
maxZoom: maxzoom + 99,
2021-04-12 14:15:50 +00:00
maxNativeZoom: maxzoom - 1,
2022-07-08 18:36:49 +00:00
tileSize: TILESIZE,
2019-11-07 18:27:34 +00:00
tms: scheme === 'tms',
opacity: this.state.opacity / 100,
detectRetina: true
});
// Associate metadata with this layer
meta.name = name + ` (${this.typeToHuman(type)})`;
meta.metaUrl = metaUrl;
layer[Symbol.for("meta")] = meta;
2019-11-14 17:16:01 +00:00
layer[Symbol.for("tile-meta")] = mres;
if (forceAddLayers || prevSelectedLayers.indexOf(layerId(layer)) !== -1){
layer.addTo(this.map);
}
// Show 3D switch button only if we have a single orthophoto
if (tiles.length === 1){
2017-12-01 18:52:36 +00:00
this.setState({singleTask: meta.task});
}
// For some reason, getLatLng is not defined for tileLayer?
// We need this function if other code calls layer.openPopup()
2019-04-01 20:49:56 +00:00
let self = this;
layer.getLatLng = function(){
2019-04-01 20:49:56 +00:00
let latlng = self.lastClickedLatLng ?
self.lastClickedLatLng :
this.options.bounds.getCenter();
return latlng;
};
var popup = L.DomUtil.create('div', 'infoWindow');
popup.innerHTML = `<div class="title">
2019-11-07 18:27:34 +00:00
${name}
</div>
<div class="popup-opacity-slider">Opacity: <input id="layerOpacity" type="range" value="${layer.options.opacity}" min="0" max="1" step="0.01" /></div>
<div>Bounds: [${layer.options.bounds.toBBoxString().split(",").join(", ")}]</div>
<ul class="asset-links loading">
<li><i class="fa fa-spin fa-sync fa-spin fa-fw"></i></li>
</ul>
<button
2017-12-01 18:52:36 +00:00
onclick="location.href='/3d/project/${meta.task.project}/task/${meta.task.id}/';"
type="button"
2017-11-24 20:20:38 +00:00
class="switchModeButton btn btn-sm btn-secondary">
<i class="fa fa-cube"></i> 3D
</button>`;
2018-03-27 18:35:16 +00:00
layer.bindPopup(popup);
$('#layerOpacity', popup).on('change input', function() {
layer.setOpacity($('#layerOpacity', popup).val());
});
2019-11-14 17:16:01 +00:00
this.setState(update(this.state, {
imageryLayers: {$push: [layer]}
}));
let mapBounds = this.mapBounds || Leaflet.latLngBounds();
mapBounds.extend(bounds);
this.mapBounds = mapBounds;
// Add camera shots layer if available
if (meta.task && meta.task.camera_shots && !this.addedCameraShots){
const shotsLayer = new L.GeoJSON.AJAX(meta.task.camera_shots, {
style: function (feature) {
return {
opacity: 1,
fillOpacity: 0.7,
color: "#000000"
}
},
pointToLayer: function (feature, latlng) {
2021-06-24 07:36:17 +00:00
return new L.CircleMarker(latlng, {
color: '#3498db',
fillColor: '#3498db',
fillOpacity: 0.9,
radius: 10,
weight: 1
});
},
onEachFeature: function (feature, layer) {
if (feature.properties && feature.properties.filename) {
let root = null;
const lazyrender = () => {
if (!root) root = document.createElement("div");
ReactDOM.render(<ImagePopup task={meta.task} feature={feature}/>, root);
return root;
}
layer.bindPopup(L.popup(
{
lazyrender,
maxHeight: 450,
minWidth: 320
}));
}
}
});
2020-12-16 19:37:35 +00:00
shotsLayer[Symbol.for("meta")] = {name: name + " " + _("(Cameras)"), icon: "fa fa-camera fa-fw"};
this.setState(update(this.state, {
overlays: {$push: [shotsLayer]}
}));
this.addedCameraShots = true;
}
2021-09-24 20:54:21 +00:00
// Add ground control points layer if available
if (meta.task && meta.task.ground_control_points && !this.addedGroundControlPoints){
const gcpMarker = L.AwesomeMarkers.icon({
icon: 'dot-circle',
markerColor: 'blue',
prefix: 'fa'
});
const gcpLayer = new L.GeoJSON.AJAX(meta.task.ground_control_points, {
style: function (feature) {
return {
opacity: 1,
fillOpacity: 0.7,
color: "#000000"
}
},
pointToLayer: function (feature, latlng) {
return new L.marker(latlng, {
icon: gcpMarker
});
},
onEachFeature: function (feature, layer) {
if (feature.properties && feature.properties.observations) {
// TODO!
let root = null;
const lazyrender = () => {
if (!root) root = document.createElement("div");
ReactDOM.render(<GCPPopup task={meta.task} feature={feature}/>, root);
return root;
}
layer.bindPopup(L.popup(
{
lazyrender,
maxHeight: 450,
minWidth: 320
}));
}
}
});
gcpLayer[Symbol.for("meta")] = {name: name + " " + _("(GCPs)"), icon: "far fa-dot-circle fa-fw"};
this.setState(update(this.state, {
overlays: {$push: [gcpLayer]}
}));
this.addedGroundControlPoints = true;
}
done();
})
.fail((_, __, err) => done(err))
);
}, err => {
if (err){
if (err !== "abort"){
this.setState({error: err.message || JSON.stringify(err)});
}
reject();
}else resolve();
});
});
}
componentDidMount() {
2018-05-04 16:37:34 +00:00
const { showBackground, tiles } = this.props;
this.map = Leaflet.map(this.container, {
scrollWheelZoom: true,
2018-02-09 17:38:42 +00:00
positionControl: true,
2019-11-05 20:47:29 +00:00
zoomControl: false,
minZoom: 0,
maxZoom: 24
});
2020-02-12 04:39:17 +00:00
// For some reason, in production this class is not added (but we need it)
// leaflet bug?
$(this.container).addClass("leaflet-touch");
PluginsAPI.Map.triggerWillAddControls({
map: this.map,
tiles
});
let scaleControl = Leaflet.control.scale({
2018-02-09 17:38:42 +00:00
maxWidth: 250,
}).addTo(this.map);
//add zoom control with your options
let zoomControl = Leaflet.control.zoom({
2018-02-09 17:38:42 +00:00
position:'bottomleft'
}).addTo(this.map);
if (showBackground) {
2018-09-06 12:18:49 +00:00
this.basemaps = {};
Basemaps.forEach((src, idx) => {
2018-09-04 23:06:04 +00:00
const { url, ...props } = src;
2019-12-10 21:00:56 +00:00
const tileProps = Utils.clone(props);
tileProps.maxNativeZoom = tileProps.maxZoom;
tileProps.maxZoom = tileProps.maxZoom + 99;
const layer = L.tileLayer(url, tileProps);
2018-09-04 23:06:04 +00:00
if (idx === 0) {
layer.addTo(this.map);
}
2018-09-06 12:18:49 +00:00
this.basemaps[props.label] = layer;
2018-09-04 23:06:04 +00:00
});
2019-02-19 21:15:28 +00:00
const customLayer = L.layerGroup();
customLayer.on("add", a => {
2022-10-19 15:11:49 +00:00
const defaultCustomBm = window.localStorage.getItem('lastCustomBasemap') || 'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png';
2020-12-16 19:37:35 +00:00
let url = window.prompt([_('Enter a tile URL template. Valid coordinates are:'),
_('{z}, {x}, {y} for Z/X/Y tile scheme'),
_('{-y} for flipped TMS-style Y coordinates'),
'',
_('Example:'),
2022-10-19 15:11:49 +00:00
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png'].join("\n"), defaultCustomBm);
2019-02-19 21:15:28 +00:00
if (url){
customLayer.clearLayers();
const l = L.tileLayer(url, {
2019-12-10 21:00:56 +00:00
maxNativeZoom: 24,
maxZoom: 99,
2019-02-19 21:15:28 +00:00
minZoom: 0
});
customLayer.addLayer(l);
l.bringToBack();
2022-10-19 15:11:49 +00:00
window.localStorage.setItem('lastCustomBasemap', url);
2019-02-19 21:15:28 +00:00
}
});
2020-12-16 19:37:35 +00:00
this.basemaps[_("Custom")] = customLayer;
this.basemaps[_("None")] = L.layerGroup();
}
this.layersControl = new LayersControl({
layers: this.state.imageryLayers,
overlays: this.state.overlays
}).addTo(this.map);
2019-11-14 17:16:01 +00:00
this.autolayers = Leaflet.control.autolayers({
overlays: {},
selectedOverlays: [],
baseLayers: this.basemaps
}).addTo(this.map);
// Drag & Drop overlays
const addDnDZone = (container, opts) => {
const mapTempLayerDrop = new Dropzone(container, opts);
mapTempLayerDrop.on("addedfile", (file) => {
this.setState({showLoading: true});
addTempLayer(file, (err, tempLayer, filename) => {
if (!err){
tempLayer.addTo(this.map);
tempLayer[Symbol.for("meta")] = {name: filename};
this.setState(update(this.state, {
overlays: {$push: [tempLayer]}
}));
//zoom to all features
this.map.fitBounds(tempLayer.getBounds());
}else{
this.setState({ error: err.message || JSON.stringify(err) });
}
this.setState({showLoading: false});
});
});
mapTempLayerDrop.on("error", (file) => {
mapTempLayerDrop.removeFile(file);
});
};
addDnDZone(this.container, {url : "/", clickable : false});
const AddOverlayCtrl = Leaflet.Control.extend({
options: {
position: 'topright'
},
onAdd: function () {
this.container = Leaflet.DomUtil.create('div', 'leaflet-control-add-overlay leaflet-bar leaflet-control');
Leaflet.DomEvent.disableClickPropagation(this.container);
const btn = Leaflet.DomUtil.create('a', 'leaflet-control-add-overlay-button');
2020-12-16 19:37:35 +00:00
btn.setAttribute("title", _("Add a temporary GeoJSON (.json) or ShapeFile (.zip) overlay"));
this.container.append(btn);
addDnDZone(btn, {url: "/", clickable: true});
return this.container;
}
});
new AddOverlayCtrl().addTo(this.map);
this.map.fitWorld();
this.map.attributionControl.setPrefix("");
2020-12-16 19:45:15 +00:00
this.setState({showLoading: true});
this.loadImageryLayers(true).then(() => {
2020-12-16 19:45:15 +00:00
this.setState({showLoading: false});
2016-11-21 21:32:37 +00:00
this.map.fitBounds(this.mapBounds);
this.map.on('click', e => {
// Find first tile layer at the selected coordinates
2019-11-14 17:16:01 +00:00
for (let layer of this.state.imageryLayers){
2016-11-21 21:32:37 +00:00
if (layer._map && layer.options.bounds.contains(e.latlng)){
2019-04-01 20:49:56 +00:00
this.lastClickedLatLng = this.map.mouseEventToLatLng(e.originalEvent);
this.updatePopupFor(layer);
2016-11-21 21:32:37 +00:00
layer.openPopup();
break;
}
2016-11-21 21:32:37 +00:00
}
}).on('popupopen', e => {
// Load task assets links in popup
if (e.popup && e.popup._source && e.popup._content && !e.popup.options.lazyrender){
const infoWindow = e.popup._content;
2019-04-01 20:49:56 +00:00
if (typeof infoWindow === 'string') return;
const $assetLinks = $("ul.asset-links", infoWindow);
if ($assetLinks.length > 0 && $assetLinks.hasClass('loading')){
const {id, project} = (e.popup._source[Symbol.for("meta")] || {}).task;
$.getJSON(`/api/projects/${project}/tasks/${id}/`)
.done(res => {
const { available_assets } = res;
const assets = AssetDownloads.excludeSeparators();
const linksHtml = assets.filter(a => available_assets.indexOf(a.asset) !== -1)
.map(asset => {
return `<li><a href="${asset.downloadUrl(project, id)}">${asset.label}</a></li>`;
})
.join("");
$assetLinks.append($(linksHtml));
})
.fail(() => {
2020-12-16 19:37:35 +00:00
$assetLinks.append($("<li>" + _("Error: cannot load assets list.") + "</li>"));
})
.always(() => {
$assetLinks.removeClass('loading');
});
}
}
if (e.popup && e.popup.options.lazyrender){
e.popup.setContent(e.popup.options.lazyrender());
}
2016-11-21 21:32:37 +00:00
});
2020-12-16 19:45:15 +00:00
}).catch(e => {
this.setState({showLoading: false, error: e.message});
});
2018-02-09 17:38:42 +00:00
PluginsAPI.Map.triggerDidAddControls({
2018-05-04 16:37:34 +00:00
map: this.map,
tiles: tiles,
controls:{
autolayers: this.autolayers,
scale: scaleControl,
zoom: zoomControl
}
});
2018-04-29 20:43:45 +00:00
PluginsAPI.Map.triggerAddActionButton({
2018-05-04 16:37:34 +00:00
map: this.map,
tiles
}, (button) => {
this.setState(update(this.state, {
pluginActionButtons: {$push: [button]}
}));
});
}
2019-11-14 17:16:01 +00:00
componentDidUpdate(prevProps, prevState) {
this.state.imageryLayers.forEach(imageryLayer => {
2019-11-07 18:27:34 +00:00
imageryLayer.setOpacity(this.state.opacity / 100);
this.updatePopupFor(imageryLayer);
});
if (prevProps.tiles !== this.props.tiles){
this.loadImageryLayers(true);
}
2019-11-14 17:16:01 +00:00
if (this.layersControl && (prevState.imageryLayers !== this.state.imageryLayers ||
prevState.overlays !== this.state.overlays)){
this.layersControl.update(this.state.imageryLayers, this.state.overlays);
2019-11-14 17:16:01 +00:00
}
}
componentWillUnmount() {
this.map.remove();
if (this.tileJsonRequests) {
this.tileJsonRequests.forEach(tileJsonRequest => tileJsonRequest.abort());
this.tileJsonRequests = [];
}
}
handleMapMouseDown(e){
// Make sure the share popup closes
if (this.shareButton) this.shareButton.hidePopup();
}
render() {
return (
<div style={{height: "100%"}} className="map">
<ErrorMessage bind={[this, 'error']} />
2019-11-07 18:27:34 +00:00
<div className="opacity-slider theme-secondary hidden-xs">
2020-12-16 19:45:15 +00:00
{_("Opacity:")} <input type="range" step="1" value={this.state.opacity} onChange={this.updateOpacity} />
2019-11-07 18:27:34 +00:00
</div>
<Standby
2020-12-16 19:45:15 +00:00
message={_("Loading...")}
show={this.state.showLoading}
/>
<div
style={{height: "100%"}}
ref={(domNode) => (this.container = domNode)}
onMouseDown={this.handleMapMouseDown}
2019-12-02 22:23:26 +00:00
/>
<div className="actionButtons">
{this.state.pluginActionButtons.map((button, i) => <div key={i}>{button}</div>)}
2021-05-26 14:55:53 +00:00
{(this.props.shareButtons && !this.props.public && this.state.singleTask !== null) ?
<ShareButton
ref={(ref) => { this.shareButton = ref; }}
task={this.state.singleTask}
linksTarget="map"
/>
: ""}
<SwitchModeButton
task={this.state.singleTask}
type="mapToModel"
public={this.props.public} />
</div>
</div>
);
}
}
2017-11-24 20:20:38 +00:00
export default Map;