diff --git a/app/static/app/img/accept.png b/app/static/app/img/accept.png new file mode 100644 index 000000000..1d85672a9 Binary files /dev/null and b/app/static/app/img/accept.png differ diff --git a/app/static/app/js/classes/Basemaps.js b/app/static/app/js/classes/Basemaps.js index 400e3cab6..8a1fe1862 100644 --- a/app/static/app/js/classes/Basemaps.js +++ b/app/static/app/js/classes/Basemaps.js @@ -21,7 +21,7 @@ export default [ { attribution: '© OpenStreetMap', - maxZoom: 21, + maxZoom: 19, minZoom: 0, label: _("OpenStreetMap"), url: "//tile.openstreetmap.org/{z}/{x}/{y}.png" diff --git a/app/static/app/js/components/EditTaskForm.jsx b/app/static/app/js/components/EditTaskForm.jsx index 64a7ae179..4d76fa7a5 100644 --- a/app/static/app/js/components/EditTaskForm.jsx +++ b/app/static/app/js/components/EditTaskForm.jsx @@ -27,7 +27,8 @@ class EditTaskForm extends React.Component { onFormChanged: PropTypes.func, inReview: PropTypes.bool, task: PropTypes.object, - suggestedTaskName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]) + suggestedTaskName: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + getCropPolygon: PropTypes.func }; constructor(props){ @@ -350,14 +351,32 @@ class EditTaskForm extends React.Component { // from a processing node) getAvailableOptionsOnly(options, availableOptions){ const optionNames = {}; + let optsCopy = Utils.clone(options); availableOptions.forEach(opt => optionNames[opt.name] = true); - return options.filter(opt => optionNames[opt.name]); + + // Override boundary and crop options (if they are available) + if (this.props.getCropPolygon){ + const poly = this.props.getCropPolygon(); + if (poly && optionNames['crop'] && optionNames['boundary']){ + let cropOpt = optsCopy.find(opt => opt.name === 'crop'); + if (!cropOpt) optsCopy.push({name: 'crop', value: "0"}); + + let boundaryOpt = optsCopy.find(opt => opt.name === 'boundary'); + if (!boundaryOpt) optsCopy.push({name: 'boundary', value: JSON.stringify(poly)}); + else boundaryOpt.value = JSON.stringify(poly); + } + } + + return optsCopy.filter(opt => optionNames[opt.name]); } getAvailableOptionsOnlyText(options, availableOptions){ const opts = this.getAvailableOptionsOnly(options, availableOptions); - let res = opts.map(opt => `${opt.name}:${opt.value}`).join(", "); + let res = opts.map(opt => { + if (opt.name === "boundary") return `${opt.name}:geojson`; + else return `${opt.name}:${opt.value}`; + }).join(", "); if (!res) res = _("Default"); return res; } diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx new file mode 100644 index 000000000..9208e0792 --- /dev/null +++ b/app/static/app/js/components/MapPreview.jsx @@ -0,0 +1,568 @@ +import React from 'react'; +import ReactDOM from 'ReactDOM'; +import '../css/MapPreview.scss'; +import 'leaflet/dist/leaflet.css'; +import Leaflet from 'leaflet'; +import PropTypes from 'prop-types'; +import $ from 'jquery'; +import ErrorMessage from './ErrorMessage'; +import Utils from '../classes/Utils'; +import '../vendor/leaflet/Leaflet.Autolayers/css/leaflet.auto-layers.css'; +import '../vendor/leaflet/Leaflet.Autolayers/leaflet-autolayers'; +import Basemaps from '../classes/Basemaps'; +import Standby from './Standby'; +import exifr from '../vendor/exifr'; +import '../vendor/leaflet/leaflet-markers-canvas'; +import { _, interpolate } from '../classes/gettext'; + +const Colors = { + fill: '#fff', + stroke: '#1a1a1a' +}; + +class MapPreview extends React.Component { + static defaultProps = { + getFiles: null, + onPolygonChange: () => {} + }; + + static propTypes = { + getFiles: PropTypes.func.isRequired, + onPolygonChange: PropTypes.func + }; + + constructor(props) { + super(props); + + this.state = { + showLoading: true, + error: "", + cropping: false + }; + + this.basemaps = {}; + this.mapBounds = null; + this.exifData = []; + this.hasTimestamp = true; + this.MaxImagesPlot = 10000; + } + + componentDidMount() { + this.map = Leaflet.map(this.container, { + scrollWheelZoom: true, + positionControl: false, + zoomControl: false, + minZoom: 0, + maxZoom: 24 + }); + + this.group = L.layerGroup(); + this.group.addTo(this.map); + + // For some reason, in production this class is not added (but we need it) + // leaflet bug? + $(this.container).addClass("leaflet-touch"); + + //add zoom control with your options + Leaflet.control.zoom({ + position:'bottomleft' + }).addTo(this.map); + + this.basemaps = {}; + + Basemaps.forEach((src, idx) => { + const { url, ...props } = src; + const tileProps = Utils.clone(props); + tileProps.maxNativeZoom = tileProps.maxZoom; + tileProps.maxZoom = tileProps.maxZoom + 99; + const layer = L.tileLayer(url, tileProps); + + if (idx === 2) { + layer.addTo(this.map); + } + + this.basemaps[props.label] = layer; + }); + + const customLayer = L.layerGroup(); + customLayer.on("add", a => { + const defaultCustomBm = window.localStorage.getItem('lastCustomBasemap') || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + + 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:'), +'https://tile.openstreetmap.org/{z}/{x}/{y}.png'].join("\n"), defaultCustomBm); + + if (url){ + customLayer.clearLayers(); + const l = L.tileLayer(url, { + maxNativeZoom: 24, + maxZoom: 99, + minZoom: 0 + }); + customLayer.addLayer(l); + l.bringToBack(); + window.localStorage.setItem('lastCustomBasemap', url); + } + }); + this.basemaps[_("Custom")] = customLayer; + this.basemaps[_("None")] = L.layerGroup(); + + this.autolayers = Leaflet.control.autolayers({ + overlays: {}, + selectedOverlays: [], + baseLayers: this.basemaps + }).addTo(this.map); + + this.map.fitBounds([ + [13.772919746115805, + 45.664640939831735], + [13.772825784981254, + 45.664591558975154]]); + this.map.attributionControl.setPrefix(""); + + this.loadNewFiles(); + } + + sampled = (arr, N) => { + // Return a uniformly sampled array with max N elements + if (arr.length <= N) return arr; + else{ + const res = []; + const step = arr.length / N; + for (let i = 0; i < N; i++){ + res.push(arr[Math.floor(i * step)]); + } + + return res; + } + }; + + loadNewFiles = () => { + this.setState({showLoading: true}); + + if (this.imagesGroup){ + this.map.removeLayer(this.imagesGroup); + this.imagesGroup = null; + } + + this.readExifData().then(() => { + let images = this.sampled(this.exifData, this.MaxImagesPlot).map(exif => { + let layer = L.circleMarker([exif.gps.latitude, exif.gps.longitude], { + radius: 8, + fillOpacity: 1, + color: "#fcfcff", //ff9e67 + fillColor: "#4b96f3", + weight: 1.5, + }).bindPopup(exif.image.name); + layer.feature = layer.feature || {}; + layer.feature.type = "Feature"; + layer.feature.properties = layer.feature.properties || {}; + layer.feature.properties["Filename"] = exif.image.name; + if (this.hasTimestamp) layer.feature.properties["Timestamp"] = exif.timestamp; + return layer; + }); + + if (this.capturePath){ + this.map.removeLayer(this.capturePath); + this.capturePath = null; + } + + // Only show line if we have reliable date/time info + if (this.hasTimestamp){ + let coords = this.exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]); + this.capturePath = L.polyline(coords, { + color: "#4b96f3", + weight: 3 + }); + this.capturePath.addTo(this.map); + } + + if (images.length > 0){ + this.imagesGroup = L.featureGroup(images).addTo(this.map); + this.map.fitBounds(this.imagesGroup.getBounds()); + } + + this.setState({showLoading: false}); + + }).catch(e => { + this.setState({showLoading: false, error: e.message}); + }); + } + + readExifData = () => { + return new Promise((resolve, reject) => { + const files = this.props.getFiles(); + const images = []; + // TODO: gcps? geo files? + + for (let i = 0; i < files.length; i++){ + const f = files[i]; + if (f.type.indexOf("image") === 0) images.push(f); + } + + // Parse EXIF + const options = { + ifd0: false, + exif: [0x9003], + gps: [0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006], + interop: false, + ifd1: false // thumbnail + }; + + const next = (i) => { + if (i < images.length - 1) parseImage(i+1); + else{ + // Sort by date/time + if (this.hasTimestamp){ + this.exifData.sort((a, b) => { + if (a.timestamp < b.timestamp) return -1; + else if (a.timestamp > b.timestamp) return 1; + else return 0; + }); + } + + resolve(); + } + }; + + const parseImage = i => { + const img = images[i]; + exifr.parse(img, options).then(exif => { + if (!exif.latitude || !exif.longitude){ + // reject(new Error(interpolate(_("Cannot extract GPS data from %(file)s"), {file: img.name}))); + next(i); + return; + } + + let dateTime = exif.DateTimeOriginal; + let timestamp = null; + if (dateTime && dateTime.getTime) timestamp = dateTime.getTime(); + if (!timestamp) this.hasTimestamp = false; + + this.exifData.push({ + image: img, + gps: { + latitude: exif.latitude, + longitude: exif.longitude, + altitude: exif.GPSAltitude !== undefined ? exif.GPSAltitude : null, + }, + timestamp + }); + + next(i); + }).catch((e) => { + console.warn(e); + next(i); + }); + }; + + if (images.length > 0) parseImage(0); + else resolve(); + }); + } + + componentWillUnmount() { + this.map.remove(); + } + + getCropPolygon = () => { + if (!this.polygon) return null; + return this.polygon.toGeoJSON(14); + } + + toggleCrop = () => { + const { cropping } = this.state; + + let crop = !cropping; + if (!crop) { + if (this.captureMarker) { + this.captureMarker.off('click', this.handleMarkerClick); + this.captureMarker.off('dblclick', this.handleMarkerDblClick); + this.captureMarker.off('mousemove', this.handleMarkerMove); + this.captureMarker.off('contextmenu', this.handleMarkerContextMenu); + + this.map.off('move', this.onMapMove); + this.map.off('resize', this.onMapResize); + + this.group.removeLayer(this.captureMarker); + this.captureMarker = null; + } + + if (this.acceptMarker) { + this.group.removeLayer(this.acceptMarker); + this.acceptMarker = null; + } + if (this.measureBoundary) { + this.group.removeLayer(this.measureBoundary); + this.measureBoundary = null; + } + if (this.measureArea) { + this.group.removeLayer(this.measureArea); + this.measureArea = null; + } + this.cropButton.blur(); + } + else{ + if (!this.captureMarker) { + this.captureMarker = L.marker(this.map.getCenter(), { + clickable: true, + zIndexOffset: 10001 + }).setIcon(L.divIcon({ + iconSize: this.map.getSize().multiplyBy(2), + className: "map-preview-marker-layer" + })).addTo(this.group); + + this.captureMarker.on('click', this.handleMarkerClick); + this.captureMarker.on('dblclick', this.handleMarkerDblClick); + this.captureMarker.on('mousemove', this.handleMarkerMove); + this.captureMarker.on('contextmenu', this.handleMarkerContextMenu); + + this.map.on('move', this.onMapMove); + this.map.on('resize', this.onMapResize); + } + + if (this.polygon){ + this.group.removeLayer(this.polygon); + this.polygon = null; + this.props.onPolygonChange(); + } + + // Reset latlngs + this.latlngs = []; + } + + + this.setState({cropping: !cropping}); + } + + handleMarkerClick = e => { + L.DomEvent.stop(e); + + const latlng = this.map.mouseEventToLatLng(e.originalEvent); + this.uniqueLatLonPush(latlng); + + if (this.latlngs.length >= 1) { + if (!this.measureBoundary) { + this.measureBoundary = L.polyline(this.latlngs.concat(latlng), { + clickable: false, + color: Colors.stroke, + weight: 2, + opacity: 0.9, + fill: false, + }).addTo(this.group); + } else { + this.measureBoundary.setLatLngs(this.latlngs.concat(latlng)); + } + } + + if (this.latlngs.length >= 2) { + if (!this.measureArea) { + this.measureArea = L.polygon(this.latlngs.concat(latlng), { + clickable: false, + stroke: false, + fillColor: Colors.fill, + fillOpacity: 0.2, + }).addTo(this.group); + } else { + this.measureArea.setLatLngs(this.latlngs.concat(latlng)); + } + } + + if (this.latlngs.length >= 3) { + if (this.acceptMarker) { + this.group.removeLayer(this.acceptMarker); + this.acceptMarker = null; + } + + const onAccept = e => { + L.DomEvent.stop(e); + this.confirmPolygon(); + return false; + }; + + let acceptLatlng = this.latlngs[0]; + + this.acceptMarker = L.marker(acceptLatlng, { + icon: L.icon({ + iconUrl: `/static/app/img/accept.png`, + iconSize: [20, 20], + iconAnchor: [10, 10], + className: "map-preview-accept-button", + }), + zIndexOffset: 99999 + }).addTo(this.group) + .on("click", onAccept) + .on("contextmenu", onAccept); + } + }; + + confirmPolygon = () => { + if (this.latlngs.length >= 3){ + const popupContainer = L.DomUtil.create('div'); + popupContainer.className = "map-preview-delete"; + const deleteLink = L.DomUtil.create('a'); + deleteLink.href = "javascript:void(0)"; + deleteLink.innerHTML = ` ${_("Delete")}`; + deleteLink.onclick = (e) => { + L.DomEvent.stop(e); + if (this.polygon){ + this.group.removeLayer(this.polygon); + this.polygon = null; + this.props.onPolygonChange(); + } + }; + popupContainer.appendChild(deleteLink); + + this.polygon = L.polygon(this.latlngs, { + clickable: true, + weight: 3, + opacity: 0.9, + color: "#ffa716", + fillColor: "#ffa716", + fillOpacity: 0.2 + }).bindPopup(popupContainer).addTo(this.group); + + this.props.onPolygonChange(); + } + + this.toggleCrop(); + } + + uniqueLatLonPush = latlng => { + if (this.latlngs.length === 0) this.latlngs.push(latlng); + else{ + const last = this.latlngs[this.latlngs.length - 1]; + if (last.lat !== latlng.lat && last.lng !== latlng.lng) this.latlngs.push(latlng); + } + }; + + handleMarkerDblClick = e => { + if (this.latlngs.length >= 2){ + const latlng = this.map.mouseEventToLatLng(e.originalEvent); + this.uniqueLatLonPush(latlng); + this.confirmPolygon(); + } + } + + handleMarkerMove = e => { + const latlng = this.map.mouseEventToLatLng(e.originalEvent); + let lls = this.latlngs.concat(latlng); + lls.push(lls[0]); + if (this.measureBoundary) { + this.measureBoundary.setLatLngs(lls); + } + if (this.measureArea) { + this.measureArea.setLatLngs(lls); + } + } + + handleMarkerContextMenu = e => { + if (this.latlngs.length >= 2){ + const latlng = this.map.mouseEventToLatLng(e.originalEvent); + this.uniqueLatLonPush(latlng); + this.confirmPolygon(); + } + + return false; + } + + onMapMove = () => { + if (this.captureMarker) this.captureMarker.setLatLng(this.map.getCenter()); + }; + + onMapResize = () => { + if (this.captureMarker) this.captureMarker.setIcon(L.divIcon({ + iconSize: this._map.getSize().multiplyBy(2) + })); + } + + download = format => { + let output = ""; + let filename = `images.${format}`; + const feats = { + type: "FeatureCollection", + features: this.exifData.map(ed => { + return { + type: "Feature", + properties: { + Filename: ed.image.name, + Timestamp: ed.timestamp + }, + geometry:{ + type: "Point", + coordinates: [ + ed.gps.longitude, + ed.gps.latitude, + ed.gps.altitude !== null ? ed.gps.altitude : 0 + ] + } + } + }) + }; + + if (format === 'geojson'){ + output = JSON.stringify(feats, null, 4); + }else if (format === 'csv'){ + output = `Filename,Timestamp,Latitude,Longitude,Altitude\r\n${feats.features.map(feat => { + return `${feat.properties.Filename},${feat.properties.Timestamp},${feat.geometry.coordinates[1]},${feat.geometry.coordinates[0]},${feat.geometry.coordinates[2]}` + }).join("\r\n")}`; + }else{ + console.error("Invalid format"); + } + + Utils.saveAs(output, filename); + } + + render() { + return ( +
+ + + + + {this.state.error === "" && this.exifData.length > this.MaxImagesPlot ? +
+ +
+ : ""} + + {this.state.error === "" ?
+ + +
: ""} + + {this.state.error === "" ? +
+ +
+ : ""} + +
(this.container = domNode)} + > + +
+ + +
+ ); + } +} + +export default MapPreview; diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index f72a2256a..9367c047d 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -4,6 +4,7 @@ import EditTaskForm from './EditTaskForm'; import PropTypes from 'prop-types'; import Storage from '../classes/Storage'; import ResizeModes from '../classes/ResizeModes'; +import MapPreview from './MapPreview'; import update from 'immutability-helper'; import PluginsAPI from '../classes/plugins/API'; import { _, interpolate } from '../classes/gettext'; @@ -34,6 +35,7 @@ class NewTaskPanel extends React.Component { taskInfo: {}, inReview: false, loading: false, + showMapPreview: false }; this.save = this.save.bind(this); @@ -44,6 +46,12 @@ class NewTaskPanel extends React.Component { this.handleFormChanged = this.handleFormChanged.bind(this); } + componentDidUpdate(prevProps, prevState){ + if (this.props.filesCount !== prevProps.filesCount && this.mapPreview){ + this.mapPreview.loadNewFiles(); + } + } + componentDidMount(){ PluginsAPI.Dashboard.triggerAddNewTaskPanelItem({}, (item) => { if (!item) return; @@ -123,6 +131,22 @@ class NewTaskPanel extends React.Component { this.setState({taskInfo: this.getTaskInfo()}); } + handleSuggestedTaskName = () => { + return this.props.suggestedTaskName(() => { + // Has GPS + this.setState({showMapPreview: true}); + }); + } + + getCropPolygon = () => { + if (!this.mapPreview) return null; + return this.mapPreview.getCropPolygon(); + }; + + handlePolygonChange = () => { + if (this.taskForm) this.taskForm.forceUpdate(); + } + render() { let filesCountOk = true; if (this.taskForm && !this.taskForm.checkFilesCount(this.props.filesCount)) filesCountOk = false; @@ -142,12 +166,19 @@ class NewTaskPanel extends React.Component { : ""} + {this.state.showMapPreview ? {this.mapPreview = domNode; }} + /> : ""} + { if (domNode) this.taskForm = domNode; }} /> diff --git a/app/static/app/js/components/ProjectListItem.jsx b/app/static/app/js/components/ProjectListItem.jsx index e689e591f..3654f64eb 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -140,7 +140,7 @@ class ProjectListItem extends React.Component { url : 'TO_BE_CHANGED', parallelUploads: 6, uploadMultiple: false, - acceptedFiles: "image/*,text/*,.las,.laz,video/*,.srt", + acceptedFiles: "image/*,text/plain,.las,.laz,video/*,.srt", autoProcessQueue: false, createImageThumbnails: false, clickable: this.uploadButton, @@ -476,7 +476,7 @@ class ProjectListItem extends React.Component { this.setState({importing: false}); } - handleTaskTitleHint = () => { + handleTaskTitleHint = (hasGPSCallback) => { return new Promise((resolve, reject) => { if (this.state.upload.files.length > 0){ @@ -501,32 +501,27 @@ class ProjectListItem extends React.Component { interop: false, ifd1: false // thumbnail }; - exifr.parse(f, options).then(gps => { - if (!gps.latitude || !gps.longitude){ + exifr.parse(f, options).then(exif => { + if (!exif.latitude || !exif.longitude){ reject(); return; } - let dateTime = gps["36867"]; + if (hasGPSCallback !== undefined) hasGPSCallback(); - // Try to parse the date from EXIF to JS - const parts = dateTime.split(" "); - if (parts.length == 2){ - let [ d, t ] = parts; - d = d.replace(/:/g, "-"); - const tm = Date.parse(`${d} ${t}`); - if (!isNaN(tm)){ - dateTime = new Date(tm).toLocaleDateString(); - } - } + let dateTime = exif.DateTimeOriginal; + if (dateTime && dateTime.toLocaleDateString) dateTime = dateTime.toLocaleDateString(); // Fallback to file modified date if // no exif info is available - if (!dateTime) dateTime = f.lastModifiedDate.toLocaleDateString(); + if (!dateTime){ + if (f.lastModifiedDate) dateTime = f.lastModifiedDate.toLocaleDateString(); + else if (f.lastModified) dateTime = new Date(f.lastModified).toLocaleDateString(); + } // Query nominatim OSM $.ajax({ - url: `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${gps.latitude}&lon=${gps.longitude}`, + url: `https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=${exif.latitude}&lon=${exif.longitude}`, contentType: 'application/json', type: 'GET' }).done(json => { diff --git a/app/static/app/js/components/TaskListItem.jsx b/app/static/app/js/components/TaskListItem.jsx index a28703076..bb783a5fe 100644 --- a/app/static/app/js/components/TaskListItem.jsx +++ b/app/static/app/js/components/TaskListItem.jsx @@ -238,7 +238,10 @@ class TaskListItem extends React.Component { if (!Array.isArray(options)) return ""; else if (options.length === 0) return "Default"; else { - return options.map(opt => `${opt.name}: ${opt.value}`).join(", "); + return options.map(opt => { + if (opt.name === "boundary") return `${opt.name}:geojson`; + else return `${opt.name}:${opt.value}` + }).join(", "); } } diff --git a/app/static/app/js/components/tests/MapPreview.test.jsx b/app/static/app/js/components/tests/MapPreview.test.jsx new file mode 100644 index 000000000..e25993a08 --- /dev/null +++ b/app/static/app/js/components/tests/MapPreview.test.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import MapPreview from '../MapPreview'; + +describe('', () => { + it('renders without exploding', () => { + const wrapper = mount( []} />); + expect(wrapper.exists()).toBe(true); + }) +}); \ No newline at end of file diff --git a/app/static/app/js/css/MapPreview.scss b/app/static/app/js/css/MapPreview.scss new file mode 100644 index 000000000..2f0c37c9a --- /dev/null +++ b/app/static/app/js/css/MapPreview.scss @@ -0,0 +1,64 @@ +.map-preview{ + position: relative; + margin-bottom: 16px; + border-radius: 3px; + .leaflet-container, .standby .cover{ + border-radius: 3px; + } + + .standby{ + z-index: 1001; + } + + .download-control{ + position: absolute; + left: 8px; + top: 8px; + z-index: 1000; + .btn:active, .btn:focus{ + outline: none; + } + } + + .crop-control{ + position: absolute; + top: 50px; + left: 8px; + z-index: 999; + .btn:active, .btn:focus{ + outline: none; + } + } + + .leaflet-control-layers-expanded{ + .leaflet-control-layers-base{ + overflow: hidden; + } + height: 200px; + overflow: hidden; + } + + .map-preview-marker-layer{ + &:hover{ + cursor: crosshair; + } + } + + .map-preview-accept-button{ + &:hover{ + cursor: pointer; + } + } + .map-preview-delete{ + min-width: 70px; + } + + .plot-warning{ + position: absolute; + z-index: 999; + left: 58px; + top: 8px; + padding: 8px; + border-radius: 4px; + } +} \ No newline at end of file diff --git a/app/static/app/js/vendor/exifr.js b/app/static/app/js/vendor/exifr.js index ed7573888..ca3aad961 100644 --- a/app/static/app/js/vendor/exifr.js +++ b/app/static/app/js/vendor/exifr.js @@ -1 +1 @@ -module.exports = require('exifr/dist/mini.umd'); \ No newline at end of file +module.exports = require('exifr/dist/full.legacy.umd'); \ No newline at end of file diff --git a/package.json b/package.json index 010ce4562..0e63a5b39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "WebODM", - "version": "2.5.4", + "version": "2.5.5", "description": "User-friendly, extendable application and API for processing aerial imagery.", "main": "index.js", "scripts": {