Image thumbnails, downloads, camera shots display in map

pull/863/head
Piero Toffanin 2020-05-17 15:34:10 -04:00
rodzic f76077775e
commit 1f2964b072
22 zmienionych plików z 705 dodań i 48 usunięć

Wyświetl plik

@ -0,0 +1,67 @@
import os
import io
from .tasks import TaskNestedView
from rest_framework import exceptions
from app.models import ImageUpload
from app.models.task import assets_directory_path
from PIL import Image
from django.http import HttpResponse
from .tasks import download_file_response
class Thumbnail(TaskNestedView):
def get(self, request, pk=None, project_pk=None, image_filename=""):
"""
Generate a thumbnail on the fly for a particular task's image
"""
task = self.get_and_check_task(request, pk)
image = ImageUpload.objects.filter(task=task, image=assets_directory_path(task.id, task.project.id, image_filename)).first()
if image is None:
raise exceptions.NotFound()
image_path = image.path()
if not os.path.isfile(image_path):
raise exceptions.NotFound()
try:
thumb_size = int(self.request.query_params.get('size', 512))
if thumb_size < 1:
raise ValueError()
quality = int(self.request.query_params.get('quality', 75))
if quality < 0 or quality > 100:
raise ValueError()
except ValueError:
raise exceptions.ValidationError("Invalid query parameters")
with Image.open(image_path) as img:
img.thumbnail((thumb_size, thumb_size))
output = io.BytesIO()
img.save(output, format='JPEG', quality=quality)
res = HttpResponse(content_type="image/jpeg")
res['Content-Disposition'] = 'inline'
res.write(output.getvalue())
output.close()
return res
class ImageDownload(TaskNestedView):
def get(self, request, pk=None, project_pk=None, image_filename=""):
"""
Download a task's image
"""
task = self.get_and_check_task(request, pk)
image = ImageUpload.objects.filter(task=task, image=assets_directory_path(task.id, task.project.id, image_filename)).first()
if image is None:
raise exceptions.NotFound()
image_path = image.path()
if not os.path.isfile(image_path):
raise exceptions.NotFound()
return download_file_response(request, image_path, 'attachment')

Wyświetl plik

@ -1,36 +0,0 @@
import os
from .tasks import TaskNestedView
from app.security import path_traversal_check
from django.core.exceptions import SuspiciousFileOperation
from rest_framework import exceptions
class Thumbnail(TaskNestedView):
def get(self, request, pk=None, project_pk=None, image_filename=""):
"""
Generate a thumbnail on the fly for a particular task's image
"""
task = self.get_and_check_task(request, pk)
image_path = task.task_path(image_filename)
try:
path_traversal_check(image_path, task.task_path(""))
except SuspiciousFileOperation:
raise exceptions.NotFound()
if not os.path.isfile(image_path):
raise exceptions.NotFound()
# TODO
return Response({
'tilejson': '2.1.0',
'name': task.name,
'version': '1.0.0',
'scheme': 'xyz',
'tiles': [get_tile_url(task, tile_type, self.request.query_params)],
'minzoom': minzoom - ZOOM_EXTRA_LEVELS,
'maxzoom': maxzoom + ZOOM_EXTRA_LEVELS,
'bounds': get_extent(task, tile_type).extent
})

Wyświetl plik

@ -4,6 +4,7 @@ from app.api.presets import PresetViewSet
from app.plugins.views import api_view_handler
from .projects import ProjectViewSet
from .tasks import TaskViewSet, TaskDownloads, TaskAssets, TaskAssetsImport
from .imageuploads import Thumbnail, ImageDownload
from .processingnodes import ProcessingNodeViewSet, ProcessingNodeOptionsView
from .admin import UserViewSet, GroupViewSet
from rest_framework_nested import routers
@ -31,8 +32,6 @@ urlpatterns = [
url(r'^', include(tasks_router.urls)),
url(r'^', include(admin_router.urls)),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/(?P<tile_type>orthophoto|dsm|dtm)/tiles\.json$', TileJson.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/(?P<tile_type>orthophoto|dsm|dtm)/bounds$', Bounds.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/(?P<tile_type>orthophoto|dsm|dtm)/metadata$', Metadata.as_view()),
@ -43,6 +42,8 @@ urlpatterns = [
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/download/(?P<asset>.+)$', TaskDownloads.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/assets/(?P<unsafe_asset_path>.+)$', TaskAssets.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/import$', TaskAssetsImport.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/images/thumbnail/(?P<image_filename>.+)$', Thumbnail.as_view()),
url(r'projects/(?P<project_pk>[^/.]+)/tasks/(?P<pk>[^/.]+)/images/download/(?P<image_filename>.+)$', ImageDownload.as_view()),
url(r'workers/check/(?P<celery_task_id>.+)', CheckTask.as_view()),
url(r'workers/get/(?P<celery_task_id>.+)', GetTaskResult.as_view()),

Wyświetl plik

@ -760,13 +760,17 @@ class Task(models.Model):
if 'dsm.tif' in self.available_assets: types.append('dsm')
if 'dtm.tif' in self.available_assets: types.append('dtm')
camera_shots = ''
if 'shots.geojson' in self.available_assets: camera_shots = '/api/projects/{}/tasks/{}/download/shots.geojson'.format(self.project.id, self.id)
return {
'tiles': [{'url': self.get_tile_base_url(t), 'type': t} for t in types],
'meta': {
'task': {
'id': str(self.id),
'project': self.project.id,
'public': self.public
'public': self.public,
'camera_shots': camera_shots
}
}
}

Wyświetl plik

@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import AssetDownloads from '../classes/AssetDownloads';
class ImagePopup extends React.Component {
static propTypes = {
feature: PropTypes.object.isRequired,
task: PropTypes.object.isRequired,
};
constructor(props){
super(props);
this.state = {
error: "",
}
}
imageOnError = () => {
this.setState({error: "Image is missing"});
}
render(){
const { error } = this.state;
const { feature, task } = this.props;
const downloadImageLink = `/api/projects/${task.project}/tasks/${task.id}/images/download/${feature.properties.filename}`;
const thumbUrl = `/api/projects/${task.project}/tasks/${task.id}/images/thumbnail/${feature.properties.filename}?size=320`;
const downloadShotsLink = `/api/projects/${task.project}/tasks/${task.id}/download/shots.geojson`;
const assetDownload = AssetDownloads.only(["shots.geojson"])[0];
return (<div>
<strong>{feature.properties.filename}</strong>
{error !== "" ? <div style={{marginTop: "8px"}}>{error}</div>
: [
<div key="image" style={{marginTop: "8px"}}>
<a href={downloadImageLink} title={feature.properties.filename}><img style={{borderRadius: "4px"}} src={thumbUrl} onError={this.imageOnError} /></a>
</div>,
<div key="download-image" style={{marginTop: "8px"}}>
<a href={downloadImageLink}><i className="fa fa-image"></i> Download Image</a>
</div>
]}
<div style={{marginTop: "8px"}}>
<a href={downloadShotsLink}><i className={assetDownload.icon}></i> {assetDownload.label} </a>
</div>
</div>);
}
}
export default ImagePopup;

Wyświetl plik

@ -13,15 +13,13 @@ export default class LayersControlLayer extends React.Component {
layer: null,
expanded: false,
map: null,
overlay: false,
overlayIcon: "fa fa-vector-square fa-fw"
overlay: false
};
static propTypes = {
layer: PropTypes.object.isRequired,
expanded: PropTypes.bool,
map: PropTypes.object.isRequired,
overlay: PropTypes.bool,
overlayIcon: PropTypes.string
overlay: PropTypes.bool
}
constructor(props){
@ -264,8 +262,8 @@ export default class LayersControlLayer extends React.Component {
}
return (<div className="layers-control-layer">
{!this.props.overlay ? <ExpandButton bind={[this, 'expanded']} /> : <div className="overlayIcon"><i className={this.props.overlayIcon}></i></div>}<Checkbox bind={[this, 'visible']}/>
<a className="layer-label" href="javascript:void(0);" onClick={this.handleLayerClick}>{meta.name}</a>
{!this.props.overlay ? <ExpandButton bind={[this, 'expanded']} /> : <div className="overlayIcon"><i className={meta.icon || "fa fa-vector-square fa-fw"}></i></div>}<Checkbox bind={[this, 'visible']}/>
<a title={meta.name} className="layer-label" href="javascript:void(0);" onClick={this.handleLayerClick}>{meta.name}</a>
{this.state.expanded ?
<div className="layer-expanded">

Wyświetl plik

@ -1,4 +1,5 @@
import React from 'react';
import ReactDOM from 'ReactDOM';
import '../css/Map.scss';
import 'leaflet/dist/leaflet.css';
import Leaflet from 'leaflet';
@ -11,6 +12,7 @@ import '../vendor/leaflet/Leaflet.Autolayers/leaflet-autolayers';
import Dropzone from '../vendor/dropzone';
import $ from 'jquery';
import ErrorMessage from './ErrorMessage';
import ImagePopup from './ImagePopup';
import SwitchModeButton from './SwitchModeButton';
import ShareButton from './ShareButton';
import AssetDownloads from '../classes/AssetDownloads';
@ -22,6 +24,8 @@ import Standby from './Standby';
import LayersControl from './LayersControl';
import update from 'immutability-helper';
import Utils from '../classes/Utils';
import '../vendor/leaflet/Leaflet.Ajax';
import '../vendor/leaflet/Leaflet.Awesome-markers';
class Map extends React.Component {
static defaultProps = {
@ -53,6 +57,7 @@ class Map extends React.Component {
this.basemaps = {};
this.mapBounds = null;
this.autolayers = null;
this.addedCameraShots = false;
this.loadImageryLayers = this.loadImageryLayers.bind(this);
this.updatePopupFor = this.updatePopupFor.bind(this);
@ -70,6 +75,20 @@ class Map extends React.Component {
$('#layerOpacity', popup.getContent()).val(layer.options.opacity);
}
typeToHuman = (type) => {
switch(type){
case "orthophoto":
return "Orthophoto";
case "plant":
return "Plant Health";
case "dsm":
return "DSM";
case "dtm":
return "DTM";
}
return "";
}
loadImageryLayers(forceAddLayers = false){
// Cancel previous requests
if (this.tileJsonRequests) {
@ -141,7 +160,7 @@ class Map extends React.Component {
});
// Associate metadata with this layer
meta.name = name;
meta.name = name + ` (${this.typeToHuman(type)})`;
meta.metaUrl = metaUrl;
layer[Symbol.for("meta")] = meta;
layer[Symbol.for("tile-meta")] = mres;
@ -197,6 +216,54 @@ class Map extends React.Component {
mapBounds.extend(bounds);
this.mapBounds = mapBounds;
// Add camera shots layer if available
if (meta.task && meta.task.camera_shots && !this.addedCameraShots){
const cameraMarker = L.AwesomeMarkers.icon({
icon: 'camera',
markerColor: 'blue',
prefix: 'fa'
});
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) {
return L.marker(latlng, {
icon: cameraMarker
});
},
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
}));
}
}
});
shotsLayer[Symbol.for("meta")] = {name: name + " (Cameras)", icon: "fa fa-camera fa-fw"};
this.setState(update(this.state, {
overlays: {$push: [shotsLayer]}
}));
this.addedCameraShots = true;
}
done();
})
.fail((_, __, err) => done(err))
@ -359,7 +426,7 @@ https://a.tile.openstreetmap.org/{z}/{x}/{y}.png
}
}).on('popupopen', e => {
// Load task assets links in popup
if (e.popup && e.popup._source && e.popup._content){
if (e.popup && e.popup._source && e.popup._content && !e.popup.options.lazyrender){
const infoWindow = e.popup._content;
if (typeof infoWindow === 'string') return;
@ -387,6 +454,10 @@ https://a.tile.openstreetmap.org/{z}/{x}/{y}.png
});
}
}
if (e.popup && e.popup.options.lazyrender){
e.popup.setContent(e.popup.options.lazyrender());
}
});
});

Wyświetl plik

@ -0,0 +1,10 @@
import React from 'react';
import { shallow } from 'enzyme';
import ImagePopup from '../ImagePopup';
describe('<ImagePopup />', () => {
it('renders without exploding', () => {
const wrapper = mount(<ImagePopup task={{id: 1, project: 1}} feature={{properties: {filename: "abc"}}} />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -91,4 +91,10 @@
.leaflet-touch .leaflet-control-add-overlay a, .leaflet-control-add-overlay a {
background-position: 2px 2px;
}
.leaflet-container{
a.leaflet-popup-close-button{
top: 8px;
right: 8px;
}
}
}

Wyświetl plik

@ -0,0 +1,51 @@
'use strict';
var jsonp = require('./jsonp');
var Promise = require('lie');
module.exports = function (url, options) {
options = options || {};
if (options.jsonp) {
return jsonp(url, options);
}
var request;
var cancel;
var out = new Promise(function (resolve, reject) {
cancel = reject;
if (global.XMLHttpRequest === undefined) {
reject('XMLHttpRequest is not supported');
}
var response;
request = new global.XMLHttpRequest();
request.open('GET', url);
if (options.headers) {
Object.keys(options.headers).forEach(function (key) {
request.setRequestHeader(key, options.headers[key]);
});
}
request.onreadystatechange = function () {
if (request.readyState === 4) {
if ((request.status < 400 && options.local) || request.status === 200) {
if (global.JSON) {
response = JSON.parse(request.responseText);
} else {
reject(new Error('JSON is not supported'));
}
resolve(response);
} else {
if (!request.status) {
reject('Attempted cross origin request without CORS enabled');
} else {
reject(request.statusText);
}
}
}
};
request.send();
});
out.catch(function (reason) {
request.abort();
return reason;
});
out.abort = cancel;
return out;
};

Wyświetl plik

@ -0,0 +1,133 @@
'use strict';
var L = global.L || require('leaflet');
var Promise = require('lie');
var ajax = require('./ajax');
L.GeoJSON.AJAX = L.GeoJSON.extend({
defaultAJAXparams: {
dataType: 'json',
callbackParam: 'callback',
local: false,
middleware: function (f) {
return f;
}
},
initialize: function (url, options) {
this.urls = [];
if (url) {
if (typeof url === 'string') {
this.urls.push(url);
} else if (typeof url.pop === 'function') {
this.urls = this.urls.concat(url);
} else {
options = url;
url = undefined;
}
}
var ajaxParams = L.Util.extend({}, this.defaultAJAXparams);
for (var i in options) {
if (this.defaultAJAXparams.hasOwnProperty(i)) {
ajaxParams[i] = options[i];
}
}
this.ajaxParams = ajaxParams;
this._layers = {};
L.Util.setOptions(this, options);
this.on('data:loaded', function () {
if (this.filter) {
this.refilter(this.filter);
}
}, this);
var self = this;
if (this.urls.length > 0) {
new Promise(function (resolve) {
resolve();
}).then(function () {
self.addUrl();
});
}
},
clearLayers: function () {
this.urls = [];
L.GeoJSON.prototype.clearLayers.call(this);
return this;
},
addUrl: function (url) {
var self = this;
if (url) {
if (typeof url === 'string') {
self.urls.push(url);
} else if (typeof url.pop === 'function') {
self.urls = self.urls.concat(url);
}
}
var loading = self.urls.length;
var done = 0;
self.fire('data:loading');
self.urls.forEach(function (url) {
if (self.ajaxParams.dataType.toLowerCase() === 'json') {
ajax(url, self.ajaxParams).then(function (d) {
var data = self.ajaxParams.middleware(d);
self.addData(data);
self.fire('data:progress', data);
}, function (err) {
self.fire('data:progress', {
error: err
});
});
} else if (self.ajaxParams.dataType.toLowerCase() === 'jsonp') {
L.Util.jsonp(url, self.ajaxParams).then(function (d) {
var data = self.ajaxParams.middleware(d);
self.addData(data);
self.fire('data:progress', data);
}, function (err) {
self.fire('data:progress', {
error: err
});
});
}
});
self.on('data:progress', function () {
if (++done === loading) {
self.fire('data:loaded');
}
});
},
refresh: function (url) {
url = url || this.urls;
this.clearLayers();
this.addUrl(url);
},
refilter: function (func) {
if (typeof func !== 'function') {
this.filter = false;
this.eachLayer(function (a) {
a.setStyle({
stroke: true,
clickable: true
});
});
} else {
this.filter = func;
this.eachLayer(function (a) {
if (func(a.feature)) {
a.setStyle({
stroke: true,
clickable: true
});
} else {
a.setStyle({
stroke: false,
clickable: false
});
}
});
}
}
});
L.Util.Promise = Promise;
L.Util.ajax = ajax;
L.Util.jsonp = require('./jsonp');
L.geoJson.ajax = function (geojson, options) {
return new L.GeoJSON.AJAX(geojson, options);
};

Wyświetl plik

@ -0,0 +1,50 @@
'use strict';
var L = global.L || require('leaflet');
var Promise = require('lie');
module.exports = function (url, options) {
options = options || {};
var head = document.getElementsByTagName('head')[0];
var scriptNode = L.DomUtil.create('script', '', head);
var cbName, ourl, cbSuffix, cancel;
var out = new Promise(function (resolve, reject) {
cancel = reject;
var cbParam = options.cbParam || 'callback';
if (options.callbackName) {
cbName = options.callbackName;
} else {
cbSuffix = '_' + ('' + Math.random()).slice(2);
cbName = '_leafletJSONPcallbacks.' + cbSuffix;
}
scriptNode.type = 'text/javascript';
if (cbSuffix) {
if (!global._leafletJSONPcallbacks) {
global._leafletJSONPcallbacks = {
length: 0
};
}
global._leafletJSONPcallbacks.length++;
global._leafletJSONPcallbacks[cbSuffix] = function (data) {
head.removeChild(scriptNode);
delete global._leafletJSONPcallbacks[cbSuffix];
global._leafletJSONPcallbacks.length--;
if (!global._leafletJSONPcallbacks.length) {
delete global._leafletJSONPcallbacks;
}
resolve(data);
};
}
if (url.indexOf('?') === -1) {
ourl = url + '?' + cbParam + '=' + cbName;
} else {
ourl = url + '&' + cbParam + '=' + cbName;
}
scriptNode.src = ourl;
}).then(null, function (reason) {
head.removeChild(scriptNode);
delete L.Util.ajax.cb[cbSuffix];
return reason;
});
out.abort = cancel;
return out;
};

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 14 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 30 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 7.8 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 535 B

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.4 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 40 KiB

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 65 KiB

Wyświetl plik

@ -0,0 +1,127 @@
/*
Leaflet.AwesomeMarkers, a plugin that adds colorful iconic markers for Leaflet, based on the Font Awesome icons
(c) 2012-2013, Lennard Voogdt
http://leafletjs.com
https://github.com/lvoogdt
*/
/*global L*/
import "./leaflet.awesome-markers.css";
(function (window, document, undefined) {
"use strict";
/*
* Leaflet.AwesomeMarkers assumes that you have already included the Leaflet library.
*/
L.AwesomeMarkers = {};
L.AwesomeMarkers.version = '2.0.1';
L.AwesomeMarkers.Icon = L.Icon.extend({
options: {
iconSize: [35, 45],
iconAnchor: [17, 42],
popupAnchor: [1, -32],
shadowAnchor: [10, 12],
shadowSize: [36, 16],
className: 'awesome-marker',
prefix: 'glyphicon',
spinClass: 'fa-spin',
extraClasses: '',
icon: 'home',
markerColor: 'blue',
iconColor: 'white'
},
initialize: function (options) {
options = L.Util.setOptions(this, options);
},
createIcon: function () {
var div = document.createElement('div'),
options = this.options;
if (options.icon) {
div.innerHTML = this._createInner();
}
if (options.bgPos) {
div.style.backgroundPosition =
(-options.bgPos.x) + 'px ' + (-options.bgPos.y) + 'px';
}
this._setIconStyles(div, 'icon-' + options.markerColor);
return div;
},
_createInner: function() {
var iconClass, iconSpinClass = "", iconColorClass = "", iconColorStyle = "", options = this.options;
if(options.icon.slice(0,options.prefix.length+1) === options.prefix + "-") {
iconClass = options.icon;
} else {
iconClass = options.prefix + "-" + options.icon;
}
if(options.spin && typeof options.spinClass === "string") {
iconSpinClass = options.spinClass;
}
if(options.iconColor) {
if(options.iconColor === 'white' || options.iconColor === 'black') {
iconColorClass = "icon-" + options.iconColor;
} else {
iconColorStyle = "style='color: " + options.iconColor + "' ";
}
}
return "<i " + iconColorStyle + "class='" + options.extraClasses + " " + options.prefix + " " + iconClass + " " + iconSpinClass + " " + iconColorClass + "'></i>";
},
_setIconStyles: function (img, name) {
var options = this.options,
size = L.point(options[name === 'shadow' ? 'shadowSize' : 'iconSize']),
anchor;
if (name === 'shadow') {
anchor = L.point(options.shadowAnchor || options.iconAnchor);
} else {
anchor = L.point(options.iconAnchor);
}
if (!anchor && size) {
anchor = size.divideBy(2, true);
}
img.className = 'awesome-marker-' + name + ' ' + options.className;
if (anchor) {
img.style.marginLeft = (-anchor.x) + 'px';
img.style.marginTop = (-anchor.y) + 'px';
}
if (size) {
img.style.width = size.x + 'px';
img.style.height = size.y + 'px';
}
},
createShadow: function () {
var div = document.createElement('div');
this._setIconStyles(div, 'shadow');
return div;
}
});
L.AwesomeMarkers.icon = function (options) {
return new L.AwesomeMarkers.Icon(options);
};
}(this, document));

Wyświetl plik

@ -0,0 +1,124 @@
/*
Author: L. Voogdt
License: MIT
Version: 1.0
*/
/* Marker setup */
.awesome-marker {
background: url('images/markers-soft.png') no-repeat 0 0;
width: 35px;
height: 46px;
position:absolute;
left:0;
top:0;
display: block;
text-align: center;
}
.awesome-marker-shadow {
background: url('images/markers-shadow.png') no-repeat 0 0;
width: 36px;
height: 16px;
}
/* Retina displays */
@media (min--moz-device-pixel-ratio: 1.5),(-o-min-device-pixel-ratio: 3/2),
(-webkit-min-device-pixel-ratio: 1.5),(min-device-pixel-ratio: 1.5),(min-resolution: 1.5dppx) {
.awesome-marker {
background-image: url('images/markers-soft@2x.png');
background-size: 720px 46px;
}
.awesome-marker-shadow {
background-image: url('images/markers-shadow@2x.png');
background-size: 35px 16px;
}
}
.awesome-marker i {
color: #333;
margin-top: 10px;
display: inline-block;
font-size: 14px;
}
.awesome-marker .icon-white {
color: #fff;
}
/* Colors */
.awesome-marker-icon-red {
background-position: 0 0;
}
.awesome-marker-icon-darkred {
background-position: -180px 0;
}
.awesome-marker-icon-lightred {
background-position: -360px 0;
}
.awesome-marker-icon-orange {
background-position: -36px 0;
}
.awesome-marker-icon-beige {
background-position: -396px 0;
}
.awesome-marker-icon-green {
background-position: -72px 0;
}
.awesome-marker-icon-darkgreen {
background-position: -252px 0;
}
.awesome-marker-icon-lightgreen {
background-position: -432px 0;
}
.awesome-marker-icon-blue {
background-position: -108px 0;
}
.awesome-marker-icon-darkblue {
background-position: -216px 0;
}
.awesome-marker-icon-lightblue {
background-position: -468px 0;
}
.awesome-marker-icon-purple {
background-position: -144px 0;
}
.awesome-marker-icon-darkpurple {
background-position: -288px 0;
}
.awesome-marker-icon-pink {
background-position: -504px 0;
}
.awesome-marker-icon-cadetblue {
background-position: -324px 0;
}
.awesome-marker-icon-white {
background-position: -574px 0;
}
.awesome-marker-icon-gray {
background-position: -648px 0;
}
.awesome-marker-icon-lightgray {
background-position: -612px 0;
}
.awesome-marker-icon-black {
background-position: -682px 0;
}

Wyświetl plik

@ -1,6 +1,6 @@
{
"name": "WebODM",
"version": "1.3.6",
"version": "1.4.0",
"description": "User-friendly, extendable application and API for processing aerial imagery.",
"main": "index.js",
"scripts": {