From 850193186bb7efbe9b64e2259e204046b3f42704 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 23 Jul 2024 17:41:10 -0400 Subject: [PATCH 01/15] PoC map preview functionality --- app/static/app/js/components/MapPreview.jsx | 248 ++++++++++++++++++ app/static/app/js/components/NewTaskPanel.jsx | 15 +- .../app/js/components/ProjectListItem.jsx | 4 +- app/static/app/js/css/MapPreview.scss | 12 + 4 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 app/static/app/js/components/MapPreview.jsx create mode 100644 app/static/app/js/css/MapPreview.scss diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx new file mode 100644 index 000000000..5eb2d36b0 --- /dev/null +++ b/app/static/app/js/components/MapPreview.jsx @@ -0,0 +1,248 @@ +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'; + +class MapPreview extends React.Component { + static defaultProps = { + getFiles: null + }; + + static propTypes = { + getFiles: PropTypes.func.isRequired + }; + + constructor(props) { + super(props); + + this.state = { + showLoading: true, + error: "" + }; + + this.basemaps = {}; + this.mapBounds = null; + } + + componentDidMount() { + this.map = Leaflet.map(this.container, { + scrollWheelZoom: true, + positionControl: false, + zoomControl: false, + minZoom: 0, + maxZoom: 24 + }); + + // 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 + let zoomControl = 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.setState({showLoading: true}); + + this.readExifData().then(res => { + const { exifData, hasDateTime } = res; + + let circles = exifData.map(exif => { + return L.circleMarker([exif.gps.latitude, exif.gps.longitude], { + radius: 8, + fillOpacity: 1, + color: "#fcfcff", //ff9e67 + fillColor: "#4b96f3", + weight: 1.5, + }); + }); + console.log(hasDateTime); + // Only show line if we have reliable date/time info + if (hasDateTime){ + let coords = exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]); + console.log(coords) + const capturePath = L.polyline(coords, { + color: "#4b96f3", + weight: 3 + }); + capturePath.addTo(this.map); + } + + let circlesGroup = L.featureGroup(circles).addTo(this.map); + + this.map.fitBounds(circlesGroup.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 = []; + const exifData = []; + let hasDateTime = true; + // 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], + interop: false, + ifd1: false // thumbnail + }; + + const parseImage = i => { + const img = images[i]; + exifr.parse(img, options).then(gps => { + if (!gps.latitude || !gps.longitude){ + reject(new Error(interpolate(_("Cannot extract GPS data from %(file)s"), {file: img.name}))); + return; + } + + let dateTime = gps["36867"]; + + // 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(); + } + } + + if (!dateTime) hasDateTime = false; + + exifData.push({ + image: img, + gps: { + latitude: gps.latitude, + longitude: gps.longitude + }, + dateTime + }); + + if (i < images.length - 1) parseImage(i+1); + else{ + // Sort by date/time + if (hasDateTime){ + exifData.sort((a, b) => { + if (a.dateTime < b.dateTime) return -1; + else if (a.dateTime > b.dateTime) return 1; + else return 0; + }); + } + + resolve({exifData, hasDateTime}); + } + }).catch(reject); + }; + + if (images.length > 0) parseImage(0); + else resolve({exifData, hasDateTime}); + }); + } + + componentWillUnmount() { + this.map.remove(); + } + + render() { + return ( +
+ + + + +
(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..ae87a17ec 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); @@ -123,6 +125,13 @@ class NewTaskPanel extends React.Component { this.setState({taskInfo: this.getTaskInfo()}); } + handleSuggestedTaskName = () => { + return this.props.suggestedTaskName(() => { + // Has GPS + this.setState({showMapPreview: true}); + }); + } + render() { let filesCountOk = true; if (this.taskForm && !this.taskForm.checkFilesCount(this.props.filesCount)) filesCountOk = false; @@ -142,12 +151,16 @@ class NewTaskPanel extends React.Component {
: ""} + {this.state.showMapPreview ? : ""} + { 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..3d30513ca 100644 --- a/app/static/app/js/components/ProjectListItem.jsx +++ b/app/static/app/js/components/ProjectListItem.jsx @@ -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){ @@ -507,6 +507,8 @@ class ProjectListItem extends React.Component { return; } + if (hasGPSCallback !== undefined) hasGPSCallback(); + let dateTime = gps["36867"]; // Try to parse the date from EXIF to JS diff --git a/app/static/app/js/css/MapPreview.scss b/app/static/app/js/css/MapPreview.scss new file mode 100644 index 000000000..f0b12993b --- /dev/null +++ b/app/static/app/js/css/MapPreview.scss @@ -0,0 +1,12 @@ +.map-preview{ + position: relative; + margin-bottom: 16px; + border-radius: 3px; + .leaflet-container, .standby .cover{ + border-radius: 3px; + } + + .standby{ + z-index: 1001; + } +} \ No newline at end of file From e1c4886aacefca96349f136418581ca9f758b932 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Tue, 23 Jul 2024 18:03:09 -0400 Subject: [PATCH 02/15] Fix map preview height, OSM basemap max zoom level --- app/static/app/js/classes/Basemaps.js | 2 +- app/static/app/js/components/MapPreview.jsx | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 5eb2d36b0..59dea01c4 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -121,13 +121,12 @@ _('Example:'), color: "#fcfcff", //ff9e67 fillColor: "#4b96f3", weight: 1.5, - }); + }).bindPopup(exif.image.name); }); - console.log(hasDateTime); + // Only show line if we have reliable date/time info if (hasDateTime){ let coords = exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]); - console.log(coords) const capturePath = L.polyline(coords, { color: "#4b96f3", weight: 3 @@ -228,7 +227,7 @@ _('Example:'), render() { return ( -
+
Date: Sat, 27 Jul 2024 08:14:27 -0400 Subject: [PATCH 03/15] Add download button for GeoJSON/CSV --- app/static/app/js/components/MapPreview.jsx | 74 ++++++++++++++++----- app/static/app/js/css/MapPreview.scss | 7 ++ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 59dea01c4..46be706ff 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -34,6 +34,7 @@ class MapPreview extends React.Component { this.basemaps = {}; this.mapBounds = null; + } componentDidMount() { @@ -112,20 +113,28 @@ _('Example:'), this.setState({showLoading: true}); this.readExifData().then(res => { - const { exifData, hasDateTime } = res; + const { exifData, hasTimestamp } = res; + + this.hasTimestamp = hasTimestamp; - let circles = exifData.map(exif => { - return L.circleMarker([exif.gps.latitude, exif.gps.longitude], { + let images = exifData.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 (hasTimestamp) layer.feature.properties["Timestamp"] = exif.timestamp; + return layer; }); // Only show line if we have reliable date/time info - if (hasDateTime){ + if (hasTimestamp){ let coords = exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]); const capturePath = L.polyline(coords, { color: "#4b96f3", @@ -134,9 +143,9 @@ _('Example:'), capturePath.addTo(this.map); } - let circlesGroup = L.featureGroup(circles).addTo(this.map); + this.imagesGroup = L.featureGroup(images).addTo(this.map); - this.map.fitBounds(circlesGroup.getBounds()); + this.map.fitBounds(this.imagesGroup.getBounds()); this.setState({showLoading: false}); @@ -151,7 +160,7 @@ _('Example:'), const files = this.props.getFiles(); const images = []; const exifData = []; - let hasDateTime = true; + let hasTimestamp = true; // TODO: gcps? geo files? for (let i = 0; i < files.length; i++){ @@ -177,19 +186,21 @@ _('Example:'), } let dateTime = gps["36867"]; + let timestamp = null; // 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(); + timestamp = new Date(tm).getTime(); } } - if (!dateTime) hasDateTime = false; + if (!timestamp) hasTimestamp = false; exifData.push({ image: img, @@ -197,27 +208,27 @@ _('Example:'), latitude: gps.latitude, longitude: gps.longitude }, - dateTime + timestamp }); if (i < images.length - 1) parseImage(i+1); else{ // Sort by date/time - if (hasDateTime){ + if (hasTimestamp){ exifData.sort((a, b) => { - if (a.dateTime < b.dateTime) return -1; - else if (a.dateTime > b.dateTime) return 1; + if (a.timestamp < b.timestamp) return -1; + else if (a.timestamp > b.timestamp) return 1; else return 0; }); } - resolve({exifData, hasDateTime}); + resolve({exifData, hasTimestamp}); } }).catch(reject); }; if (images.length > 0) parseImage(0); - else resolve({exifData, hasDateTime}); + else resolve({exifData, hasTimestamp}); }); } @@ -225,6 +236,24 @@ _('Example:'), this.map.remove(); } + download = format => { + let output = ""; + let filename = `images.${format}`; + const feats = this.imagesGroup.toGeoJSON(14); + + if (format === 'geojson'){ + output = JSON.stringify(feats, null, 4); + }else if (format === 'csv'){ + output = `Filename,Timestamp,Latitude,Longitude\r\n${feats.features.map(feat => { + return `${feat.properties.Filename},${feat.properties.Timestamp},${feat.geometry.coordinates[1]},${feat.geometry.coordinates[0]}` + }).join("\r\n")}`; + }else{ + console.error("Invalid format"); + } + + Utils.saveAs(output, filename); + } + render() { return (
@@ -234,11 +263,24 @@ _('Example:'), message={_("Plotting GPS locations...")} show={this.state.showLoading} /> + + {this.state.error === "" ? : ""}
(this.container = domNode)} - /> + /> +
); } diff --git a/app/static/app/js/css/MapPreview.scss b/app/static/app/js/css/MapPreview.scss index f0b12993b..c222a72ee 100644 --- a/app/static/app/js/css/MapPreview.scss +++ b/app/static/app/js/css/MapPreview.scss @@ -9,4 +9,11 @@ .standby{ z-index: 1001; } + + .download-control{ + position: absolute; + left: 8px; + top: 8px; + z-index: 999; + } } \ No newline at end of file From 2046a6f4478a7dc04b6f546d69f5d0ec98074a3c Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Sat, 27 Jul 2024 08:50:11 -0400 Subject: [PATCH 04/15] Add crop button --- app/static/app/js/components/MapPreview.jsx | 29 ++++++++++++++++++--- app/static/app/js/css/MapPreview.scss | 19 ++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 46be706ff..da7f5a4d4 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -29,7 +29,8 @@ class MapPreview extends React.Component { this.state = { showLoading: true, - error: "" + error: "", + cropping: false }; this.basemaps = {}; @@ -236,6 +237,15 @@ _('Example:'), this.map.remove(); } + toggleCrop = () => { + const { cropping } = this.state; + + let crop = !cropping; + if (!crop) this.cropButton.blur(); + + this.setState({cropping: !cropping}); + } + download = format => { let output = ""; let filename = `images.${format}`; @@ -265,7 +275,7 @@ _('Example:'), /> {this.state.error === "" ?
-
    @@ -275,11 +285,22 @@ _('Example:'),
: ""} - + + {this.state.error === "" ? +
+ +
+ : ""} +
(this.container = domNode)} - /> + > + +
+
); diff --git a/app/static/app/js/css/MapPreview.scss b/app/static/app/js/css/MapPreview.scss index c222a72ee..ddb4dccc9 100644 --- a/app/static/app/js/css/MapPreview.scss +++ b/app/static/app/js/css/MapPreview.scss @@ -14,6 +14,25 @@ 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{ + height: 200px; + overflow: hidden; + } + } \ No newline at end of file From 881305052b5234329ba9d1d61d08687207596de2 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 29 Jul 2024 13:31:48 -0400 Subject: [PATCH 05/15] Polygon draw working --- app/static/app/img/accept.png | Bin 0 -> 4878 bytes app/static/app/js/components/MapPreview.jsx | 207 +++++++++++++++++++- app/static/app/js/css/MapPreview.scss | 17 ++ 3 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 app/static/app/img/accept.png diff --git a/app/static/app/img/accept.png b/app/static/app/img/accept.png new file mode 100644 index 0000000000000000000000000000000000000000..1d85672a921e6cf02e564e5291ef27667c6a7d5c GIT binary patch literal 4878 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 n^qjlVs{`M*jT_z7F8P&x|JBt(B55 { + 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; + } + }; + 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.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}`; @@ -288,7 +489,7 @@ _('Example:'), {this.state.error === "" ?
-
diff --git a/app/static/app/js/css/MapPreview.scss b/app/static/app/js/css/MapPreview.scss index ddb4dccc9..e44f2325b 100644 --- a/app/static/app/js/css/MapPreview.scss +++ b/app/static/app/js/css/MapPreview.scss @@ -31,8 +31,25 @@ } .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; + } } \ No newline at end of file From b6f037e962636213581b3f205a4c454ca7100108 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 29 Jul 2024 14:30:34 -0400 Subject: [PATCH 06/15] Handle options, crop, multiple file selection --- app/static/app/js/components/EditTaskForm.jsx | 20 +++++- app/static/app/js/components/MapPreview.jsx | 65 ++++++++++++------- app/static/app/js/components/NewTaskPanel.jsx | 20 +++++- 3 files changed, 79 insertions(+), 26 deletions(-) diff --git a/app/static/app/js/components/EditTaskForm.jsx b/app/static/app/js/components/EditTaskForm.jsx index 64a7ae179..c5aca3277 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,9 +351,24 @@ 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){ diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index 34e66fe31..c0009f892 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -22,11 +22,13 @@ const Colors = { class MapPreview extends React.Component { static defaultProps = { - getFiles: null + getFiles: null, + onPolygonChange: () => {} }; - + static propTypes = { - getFiles: PropTypes.func.isRequired + getFiles: PropTypes.func.isRequired, + onPolygonChange: PropTypes.func }; constructor(props) { @@ -40,7 +42,8 @@ class MapPreview extends React.Component { this.basemaps = {}; this.mapBounds = null; - + this.exifData = []; + this.hasTimestamp = true; } componentDidMount() { @@ -119,14 +122,19 @@ _('Example:'), 45.664591558975154]]); this.map.attributionControl.setPrefix(""); - this.setState({showLoading: true}); + this.loadNewFiles(); + } - this.readExifData().then(res => { - const { exifData, hasTimestamp } = res; + loadNewFiles = () => { + this.setState({showLoading: true}); - this.hasTimestamp = hasTimestamp; + if (this.imagesGroup){ + this.map.removeLayer(this.imagesGroup); + this.imagesGroup = null; + } - let images = exifData.map(exif => { + this.readExifData().then(() => { + let images = this.exifData.map(exif => { let layer = L.circleMarker([exif.gps.latitude, exif.gps.longitude], { radius: 8, fillOpacity: 1, @@ -138,18 +146,23 @@ _('Example:'), layer.feature.type = "Feature"; layer.feature.properties = layer.feature.properties || {}; layer.feature.properties["Filename"] = exif.image.name; - if (hasTimestamp) layer.feature.properties["Timestamp"] = exif.timestamp; + 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 (hasTimestamp){ - let coords = exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]); - const capturePath = L.polyline(coords, { + if (this.hasTimestamp){ + let coords = this.exifData.map(exif => [exif.gps.latitude, exif.gps.longitude]); + this.capturePath = L.polyline(coords, { color: "#4b96f3", weight: 3 }); - capturePath.addTo(this.map); + this.capturePath.addTo(this.map); } this.imagesGroup = L.featureGroup(images).addTo(this.map); @@ -161,15 +174,12 @@ _('Example:'), }).catch(e => { this.setState({showLoading: false, error: e.message}); }); - } readExifData = () => { return new Promise((resolve, reject) => { const files = this.props.getFiles(); const images = []; - const exifData = []; - let hasTimestamp = true; // TODO: gcps? geo files? for (let i = 0; i < files.length; i++){ @@ -209,9 +219,9 @@ _('Example:'), } } - if (!timestamp) hasTimestamp = false; + if (!timestamp) this.hasTimestamp = false; - exifData.push({ + this.exifData.push({ image: img, gps: { latitude: gps.latitude, @@ -223,21 +233,21 @@ _('Example:'), if (i < images.length - 1) parseImage(i+1); else{ // Sort by date/time - if (hasTimestamp){ - exifData.sort((a, b) => { + 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({exifData, hasTimestamp}); + resolve(); } }).catch(reject); }; if (images.length > 0) parseImage(0); - else resolve({exifData, hasTimestamp}); + else resolve(); }); } @@ -245,6 +255,11 @@ _('Example:'), this.map.remove(); } + getCropPolygon = () => { + if (!this.polygon) return null; + return this.polygon.toGeoJSON(14); + } + toggleCrop = () => { const { cropping } = this.state; @@ -299,6 +314,7 @@ _('Example:'), if (this.polygon){ this.group.removeLayer(this.polygon); this.polygon = null; + this.props.onPolygonChange(); } // Reset latlngs @@ -382,6 +398,7 @@ _('Example:'), if (this.polygon){ this.group.removeLayer(this.polygon); this.polygon = null; + this.props.onPolygonChange(); } }; popupContainer.appendChild(deleteLink); @@ -394,6 +411,8 @@ _('Example:'), fillColor: "#ffa716", fillOpacity: 0.2 }).bindPopup(popupContainer).addTo(this.group); + + this.props.onPolygonChange(); } this.toggleCrop(); diff --git a/app/static/app/js/components/NewTaskPanel.jsx b/app/static/app/js/components/NewTaskPanel.jsx index ae87a17ec..9367c047d 100644 --- a/app/static/app/js/components/NewTaskPanel.jsx +++ b/app/static/app/js/components/NewTaskPanel.jsx @@ -46,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; @@ -132,6 +138,15 @@ class NewTaskPanel extends React.Component { }); } + 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; @@ -152,7 +167,9 @@ class NewTaskPanel extends React.Component { : ""} {this.state.showMapPreview ? {this.mapPreview = domNode; }} /> : ""} { if (domNode) this.taskForm = domNode; }} /> From f60a7aeccba810612a6da04a55c1c1c71a405b90 Mon Sep 17 00:00:00 2001 From: Piero Toffanin Date: Mon, 29 Jul 2024 15:00:36 -0400 Subject: [PATCH 07/15] Plot limit warning --- app/static/app/js/components/MapPreview.jsx | 43 ++++++++++++++++++++- app/static/app/js/css/MapPreview.scss | 9 +++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/app/static/app/js/components/MapPreview.jsx b/app/static/app/js/components/MapPreview.jsx index c0009f892..107b4b12b 100644 --- a/app/static/app/js/components/MapPreview.jsx +++ b/app/static/app/js/components/MapPreview.jsx @@ -44,6 +44,7 @@ class MapPreview extends React.Component { this.mapBounds = null; this.exifData = []; this.hasTimestamp = true; + this.MaxImagesPlot = 10000; } componentDidMount() { @@ -125,6 +126,20 @@ _('Example:'), 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}); @@ -134,7 +149,7 @@ _('Example:'), } this.readExifData().then(() => { - let images = this.exifData.map(exif => { + let images = this.sampled(this.exifData, this.MaxImagesPlot).map(exif => { let layer = L.circleMarker([exif.gps.latitude, exif.gps.longitude], { radius: 8, fillOpacity: 1, @@ -469,7 +484,25 @@ _('Example:'), download = format => { let output = ""; let filename = `images.${format}`; - const feats = this.imagesGroup.toGeoJSON(14); + 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 + ] + } + } + }) + }; if (format === 'geojson'){ output = JSON.stringify(feats, null, 4); @@ -494,6 +527,12 @@ _('Example:'), show={this.state.showLoading} /> + {this.state.error === "" && this.exifData.length > this.MaxImagesPlot ? +
+ +
+ : ""} + {this.state.error === "" ?