From 5d3ccbd222ee4fb871324a8366a774c9a43f4194 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Sat, 5 Oct 2019 20:30:01 -0500 Subject: [PATCH] feat: Add map export dialog --- package.json | 4 +- .../components/MapFilter/MapExportDialog.js | 301 ++++++++++++++++++ .../MapFilter/MapExportDialog.stories.js | 227 +++++++++++++ .../components/MapFilter/MapFilter.js | 13 +- src/renderer/components/MapFilter/Toolbar.js | 103 +++--- src/renderer/new-api.js | 2 +- 6 files changed, 607 insertions(+), 43 deletions(-) create mode 100644 src/renderer/components/MapFilter/MapExportDialog.js create mode 100644 src/renderer/components/MapFilter/MapExportDialog.stories.js diff --git a/package.json b/package.json index 402a4b109..0c50a8f43 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,7 @@ "mirror-folder": "^2.1.1", "mkdirp": "^0.5.1", "object-assign": "^4.1.1", - "once": "^1.3.3", + "once": "^1.4.0", "osm-p2p": "^4.0.0", "osm-p2p-server": "^5.0.0", "prop-types": "^15.6.0", @@ -116,6 +116,7 @@ "react-mapfilter": "^3.0.0-beta.7", "react-spinners": "^0.6.1", "react-transition-group": "^4.3.0", + "request": "^2.88.0", "run-parallel": "^1.1.9", "run-series": "^1.1.4", "safe-fs-blob-store": "^1.0.0", @@ -129,7 +130,6 @@ "to2": "^1.0.0", "traverse": "^0.6.6", "websocket-stream": "^3.1.0", - "xhr": "^2.5.0", "xtend": "^4.0.1" }, "devDependencies": { diff --git a/src/renderer/components/MapFilter/MapExportDialog.js b/src/renderer/components/MapFilter/MapExportDialog.js new file mode 100644 index 000000000..464299512 --- /dev/null +++ b/src/renderer/components/MapFilter/MapExportDialog.js @@ -0,0 +1,301 @@ +import React, { useState } from 'react' +import { makeStyles } from '@material-ui/core/styles' +import Button from '@material-ui/core/Button' +import Dialog from '@material-ui/core/Dialog' +import DialogActions from '@material-ui/core/DialogActions' +import DialogContent from '@material-ui/core/DialogContent' +import DialogTitle from '@material-ui/core/DialogTitle' +import { TextField, DialogContentText } from '@material-ui/core' +import { defineMessages, useIntl, FormattedMessage } from 'react-intl' +import archiver from 'archiver' +import logger from 'electron-timber' +import fs from 'fs' +import path from 'path' +import request from 'request' +import { remote } from 'electron' + +import ViewWrapper from 'react-mapfilter/commonjs/ViewWrapper' + +const msgs = defineMessages({ + // Title for webmaps export dialog + title: 'Export a map to share online', + // Save button + save: 'Save', + // cancel button + cancel: 'Cancel', + // Label for field to enter map title + titleLabel: 'Map Title', + // Label for field to enter map description + descriptionLabel: 'Map Description', + // Label for field to enter terms and conditions + termsLabel: 'Terms & Limitations', + // Helper text explaining terms and conditions field + termsHint: 'Add terms & limitations about how this data can be used', + // Label for field to enter custom map style + styleLabel: 'Map Style' +}) + +// const defaultMapStyle = 'mapbox://styles/mapbox/outdoors-v11' + +const EditDialogContent = ({ + open, + observations, + getPreset, + getMediaUrl, + onClose +}) => { + const classes = useStyles() + const { formatMessage } = useIntl() + const [saving, setSaving] = useState() + const [title, setTitle] = useState() + const [description, setDescription] = useState() + // const [terms, setTerms] = useState() + // const [mapStyle, setMapStyle] = useState(defaultMapStyle) + + const handleClose = () => { + setSaving(false) + setTitle(undefined) + setDescription(undefined) + // setTerms(undefined) + // setMapStyle(defaultMapStyle) + onClose() + } + + const handleSave = e => { + e.preventDefault() + setSaving(true) + const points = observationsToGeoJson(observations, getPreset) + const metadata = { title: title || '', description: description || '' } + + remote.dialog.showSaveDialog( + { + title: 'Guardar Mapa', + defaultPath: 'mapa-para-web', + filters: [{ extensions: ['mapeomap'] }] + }, + filepath => { + const filepathWithExtension = path.join( + path.dirname(filepath), + path.basename(filepath, '.mapeomap') + '.mapeomap' + ) + createArchive(filepathWithExtension, err => { + console.log('done', err) + handleClose() + }) + } + ) + + function createArchive (filepath, cb) { + var output = fs.createWriteStream(filepath) + + var archive = archiver('zip', { + zlib: { level: 9 } // Sets the compression level. + }) + + output.on('end', function () { + console.log('Data has been drained') + }) + + // listen for all archive data to be written + // 'close' event is fired only when a file descriptor is involved + output.on('close', function () { + logger.log('Exported map ' + archive.pointer() + ' total bytes') + cb() + }) + + archive.on('warning', err => { + if (err.code === 'ENOENT') { + logger.warn(err) + } else { + cb(err) + } + }) + + archive.on('error', err => { + cb(err) + }) + + archive.pipe(output) + + archive.append(JSON.stringify(points, null, 2), { name: 'points.json' }) + archive.append(JSON.stringify(metadata, null, 2), { + name: 'metadata.json' + }) + + points.features.forEach(point => { + const imageId = point.properties.image + if (!imageId) return + const imageStream = request(getMediaUrl(imageId, 'original')) + console.log(getMediaUrl(imageId)) + imageStream.on('error', console.error) + archive.append(imageStream, { name: 'images/' + imageId }) + }) + + archive.finalize() + } + } + + return ( + +
+ + + + + + + {`Vas a exportar ${ + observations.length + } puntos a un mapa para compartir por internet.`} + + setTitle(e.target.value)} + margin='normal' + /> + setDescription(e.target.value)} + /> + {/** + setTerms(e.target.value)} + /> + + Enter a{' '} + + Mapbox Style Url + + + } + fullWidth + variant='outlined' + margin='normal' + onChange={e => setMapStyle(e.target.value)} + /> + */} + + + + + + +
+
+ ) +} + +export default function EditDialog ({ + observations, + presets, + filter, + getMediaUrl, + ...otherProps +}) { + return ( + + {({ onClickObservation, filteredObservations, getPreset }) => ( + + )} + + ) +} + +const useStyles = makeStyles(theme => ({ + appBar: { + position: 'relative' + }, + title: { + marginLeft: theme.spacing(2), + flex: 1 + }, + content: { + display: 'flex', + flexDirection: 'column', + paddingTop: 0 + } +})) + +function observationsToGeoJson (observations = [], getPreset) { + const features = observations.map(obs => { + const preset = getPreset(obs) + const title = preset ? preset.name : 'Observación' + const description = obs.tags && (obs.tags.notes || obs.tags.note) + const date = obs.created_at + const image = + obs.attachments && obs.attachments.length > 0 + ? obs.attachments[obs.attachments.length - 1].id + : undefined + const coords = obs.lon != null && obs.lat != null && [obs.lon, obs.lat] + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: coords + }, + properties: { + title, + description, + date, + image + } + } + }) + return { + type: 'FeatureCollection', + features + } +} diff --git a/src/renderer/components/MapFilter/MapExportDialog.stories.js b/src/renderer/components/MapFilter/MapExportDialog.stories.js new file mode 100644 index 000000000..9018d1d09 --- /dev/null +++ b/src/renderer/components/MapFilter/MapExportDialog.stories.js @@ -0,0 +1,227 @@ +import React from 'react' +import { action } from '@storybook/addon-actions' + +import MapExportDialog from './MapExportDialog' + +export default { + title: 'Map Export' +} + +const testObs = [ + { + lon: -76.2040014, + lat: -0.1983257, + attachments: [ + { + id: '4abe1a942e0a5cd5846d4dcb6f96ce2d.jpg', + type: 'image/jpeg' + } + ], + tags: { + categoryId: 'asentamiento-ilegal', + notes: + 'Invasión,sembrios de cacao está hectárea 2 tiene aproximadamente de los invasores' + }, + metadata: { + location: { + error: false, + permission: 'granted', + provider: { + gpsAvailable: true, + passiveAvailable: true, + locationServicesEnabled: true, + networkAvailable: true + }, + position: { + timestamp: 1560894283000, + mocked: false, + coords: { + altitude: 241.2, + heading: 282, + longitude: -76.2040014, + speed: 0, + latitude: -0.1983257, + accuracy: 3 + } + } + } + }, + schemaVersion: 3, + type: 'observation', + timestamp: '2019-10-02T19:21:44.211Z', + created_at: '2019-06-18T21:46:45.703Z', + id: '002346c8a8921f5c', + links: [ + '08764aa2dbfd49f42f879950d364c8c28d2e6374de48d9982281ffc88dfb7223@0' + ], + version: + '08764aa2dbfd49f42f879950d364c8c28d2e6374de48d9982281ffc88dfb7223@1' + }, + { + lon: -76.1821115, + lat: -0.2078845, + attachments: [ + { + id: '05d48eb4b7f6de9f3d801f4c875c8c74.jpg', + type: 'image/jpeg' + }, + { + id: 'c511570a32c64881730db16f8a3e16d0.jpg', + type: 'image/jpeg' + }, + { + id: '99e44ee66b4d8528753d45a3bd7f8f09.jpg', + type: 'image/jpeg' + } + ], + tags: { + categoryId: 'tala-ilegal', + notes: + 'Amarillo canelo talado de más o menos 30 metros y de unos 30 @ños de vida.... 😭 Para vender son tablones más o menos 200 \nTerrible ' + }, + metadata: { + location: { + error: false, + permission: 'granted', + provider: { + backgroundModeEnabled: true, + gpsAvailable: true, + passiveAvailable: true, + locationServicesEnabled: true, + networkAvailable: true + }, + position: { + timestamp: 1562097313000, + mocked: false, + coords: { + altitude: 218.7, + heading: 269, + longitude: -76.1821115, + speed: 0.5677385926246643, + latitude: -0.2078845, + accuracy: 3 + } + } + } + }, + schemaVersion: 3, + type: 'observation', + timestamp: '2019-07-02T19:59:34.359Z', + created_at: '2019-07-02T19:56:48.373Z', + id: '00e45c12cb026d67', + links: [ + '9dd59c141187453d6232c0600ea577b7e09dbb20a42a8c463057bb1f7c20ea54@86' + ], + version: + '9dd59c141187453d6232c0600ea577b7e09dbb20a42a8c463057bb1f7c20ea54@87' + }, + { + lon: -76.0480426, + lat: 0.0957959, + attachments: [ + { + id: 'cbbb8569d886a4a8e0f0a2c22512c53b.jpg', + type: 'image/jpeg' + } + ], + tags: { + categoryId: 'tala-ilegal', + notes: 'Guabillo tumbado directo sobre el lindero hace un mes' + }, + metadata: { + location: { + error: false, + permission: 'granted', + provider: { + backgroundModeEnabled: true, + gpsAvailable: true, + passiveAvailable: true, + locationServicesEnabled: true, + networkAvailable: true + }, + position: { + timestamp: 1563312915000, + mocked: false, + coords: { + altitude: 250.3, + heading: 264, + longitude: -76.0480426, + speed: 0, + latitude: 0.0957959, + accuracy: 3 + } + } + } + }, + schemaVersion: 3, + type: 'observation', + timestamp: '2019-07-16T21:36:11.035Z', + created_at: '2019-07-16T21:36:11.035Z', + id: '01227dae92589572', + links: [], + version: + '9dd59c141187453d6232c0600ea577b7e09dbb20a42a8c463057bb1f7c20ea54@109' + }, + { + lon: -76.1001875, + lat: 0.0897293, + attachments: [ + { + id: 'b937221a693ab0dd5369110c24e206ff.jpg', + type: 'image/jpeg' + }, + { + id: '760b2e0006efb14e9e76308b6031e562.jpg', + type: 'image/jpeg' + } + ], + tags: { + categoryId: 'sendero-cazadores', + notes: 'Cruze del lindero de Tase' + }, + metadata: { + location: { + error: false, + permission: 'granted', + provider: { + backgroundModeEnabled: true, + gpsAvailable: true, + passiveAvailable: true, + locationServicesEnabled: true, + networkAvailable: true + }, + position: { + timestamp: 1563388482000, + mocked: false, + coords: { + altitude: 242.1, + heading: 8, + longitude: -76.1001875, + speed: 0.273832231760025, + latitude: 0.0897293, + accuracy: 3 + } + } + } + }, + schemaVersion: 3, + type: 'observation', + timestamp: '2019-07-17T18:35:18.640Z', + created_at: '2019-07-17T18:35:18.640Z', + id: '016de602f27dde8b', + links: [], + version: + '9dd59c141187453d6232c0600ea577b7e09dbb20a42a8c463057bb1f7c20ea54@136' + } +] + +const getMediaUrl = id => `http://127.0.0.1:5000/media/preview/${id}` + +export const defaultStory = () => ( + +) diff --git a/src/renderer/components/MapFilter/MapFilter.js b/src/renderer/components/MapFilter/MapFilter.js index a7147f84b..d0baa705a 100644 --- a/src/renderer/components/MapFilter/MapFilter.js +++ b/src/renderer/components/MapFilter/MapFilter.js @@ -2,7 +2,6 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { makeStyles } from '@material-ui/core/styles' import { MapView, ReportView, MediaView } from 'react-mapfilter' import debounce from 'lodash/debounce' -import { defineMessages } from 'react-intl' import logger from 'electron-timber' import Toolbar from './Toolbar' @@ -169,7 +168,8 @@ const MapFilter = () => { ) } - console.log(position.current) + if (observationsError) console.error(observationsError) + if (presetsError) console.error(presetsError) return (
@@ -181,7 +181,14 @@ const MapFilter = () => { fields={fields} />
- + { ) } -const MapFilterToolbar = ({ view, onChange }) => { +const MapFilterToolbar = ({ + view, + onChange, + observations, + filter, + presets, + getMediaUrl +}) => { const cx = useStyles() const { formatMessage: t } = useIntl() + const [mapExportOpen, setMapExportOpen] = useState(false) + const handleChange = view => e => onChange(view) return ( - - -
- - - Map - - - - Media - - - - Report - -
- - - - - -
-
+ <> + + +
+ + + Map + + + + Media + + + + Report + +
+ + setMapExportOpen(true)} + > + + + +
+
+ setMapExportOpen(false)} + /> + ) } diff --git a/src/renderer/new-api.js b/src/renderer/new-api.js index 1df6d1577..64048ba3c 100644 --- a/src/renderer/new-api.js +++ b/src/renderer/new-api.js @@ -164,7 +164,7 @@ export function Api ({ baseUrl }) { }, // Return the url for a media attachment - getMediaUrl: function getMediaUrl (attachmentId, size) { + getMediaUrl: function getMediaUrl (attachmentId, size = 'preview') { return `${baseUrl}media/${size}/${attachmentId}` },