diff --git a/CHANGELOG.md b/CHANGELOG.md index a0dd6e132..70e06d3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ _Breaking developer changes, which may affect downstream projects or sites that #### :camera: Street-Level * Add [_Mapilio_](https://mapilio.com/openstreetmap) as new street-level imagery provider ([#9664], thanks [@channel-s]) * Add photos from the [Norwegian Public Road Administration](https://vegbilder.atlas.vegvesen.no/) as new street-level imagery provider in Norway ([#9509], thanks [@noenandre]) +* Add functionality to display georeferenced photos from local files ([#9291], thanks [@nontech]) * Gray out street level layers in "Map Data" pane when map is zoomed out too far #### :white_check_mark: Validation #### :bug: Bugfixes @@ -61,12 +62,14 @@ _Breaking developer changes, which may affect downstream projects or sites that [#8997]: https://github.com/openstreetmap/iD/issues/8997 [#9233]: https://github.com/openstreetmap/iD/issues/9233 +[#9291]: https://github.com/openstreetmap/iD/pull/9291 [#9509]: https://github.com/openstreetmap/iD/pull/9509 [#9664]: https://github.com/openstreetmap/iD/pull/9664 [#9786]: https://github.com/openstreetmap/iD/issues/9786 [#9817]: https://github.com/openstreetmap/iD/pull/9817 [@channel-s]: https://github.com/channel-s [@noenandre]: https://github.com/noenandre +[@nontech]: https://github.com/nontech # 2.26.2 diff --git a/css/60_photos.css b/css/60_photos.css index 4b655c853..b5f7957a3 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -409,12 +409,12 @@ label.streetside-hires { transform-origin: 0 0; } -.vegbilder-wrapper { +.photo-wrapper { position: relative; background-color: #000; } -.vegbilder-wrapper .plane-frame { +.photoviewer .plane-frame { display: block; overflow: hidden; height: 100%; @@ -424,7 +424,7 @@ label.streetside-hires { background-repeat: no-repeat; } -.vegbilder-wrapper .plane-frame > img.plane-photo{ +.photoviewer .plane-frame > img.plane-photo{ width: auto; height: 100%; transform-origin: 0 0; @@ -471,3 +471,100 @@ label.streetside-hires { color: #fff; } } + +/* local georeferenced photos */ +.layer-local-photos { + pointer-events: none; +} +.layer-local-photos .viewfield-group * { + fill: #ed00d9; +} +.local-photos { + display: flex; +} +.local-photos > div { + width: 50%; +} +.local-photos > div:first-child { + margin-right: 20px; +} + +.list-local-photos { + max-height: 40vh; + overflow-y: scroll; + overflow-x: auto; + /* workaround for something like "overflow-x: visible" + see https://stackoverflow.com/a/39554003 */ + margin-left: -100px; + padding-left: 100px; +} +.list-local-photos::-webkit-scrollbar { + border-left: none; +} +.list-local-photos li { + list-style: none; + display: flex; + justify-content: space-between; + height: 30px; +} +.list-local-photos span.filename { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 30px; + padding-left: 8px; + border-bottom: 1px solid #ccc; + border-left: 1px solid #ccc; + border-right: 1px solid #ccc; +} +.list-local-photos li:first-child span.filename { + border-top: 1px solid #ccc; + border-top-left-radius: 4px; +} +.list-local-photos li:first-child button { + border-top: 1px solid #ccc; +} +.list-local-photos li:first-child button.remove { + border-top-right-radius: 4px; +} +.list-local-photos li:last-child span.filename { + border-bottom-left-radius: 4px; +} +.list-local-photos li:last-child button.remove { + border-bottom-right-radius: 4px; +} +.list-local-photos li.invalid span.filename { + color: #ccc; +} +.list-local-photos li.invalid button.zoom-to-data { + display: none; +} +.list-local-photos li button.no-geolocation { + display: none; +} +.list-local-photos li.invalid button.no-geolocation { + display: block; +} +.list-local-photos .placeholder div { + display: block; + height: 40px; + width: 40px; + background-position: center; + background-size: cover; + background-repeat: no-repeat; + background-image: url(img/loader-black.gif); + filter: invert(1); +} +.local-photos label.button { + background: #7092ff; + color: #fff; + font-weight: bold; + padding: 10px 25px; + text-align: center; + font-size: 12px; + display: inline-block; + border-radius: 4px; + cursor: pointer; +} \ No newline at end of file diff --git a/css/80_app.css b/css/80_app.css index faab82563..ca774fc5f 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -5650,7 +5650,7 @@ li.hide + li.version .badge .tooltip .popover-arrow { /* Scrollbars ----------------------------------------------------- */ ::-webkit-scrollbar { - height: 20px; + height: 10px; overflow: visible; width: 10px; border-left: 1px solid #DDD; @@ -5677,6 +5677,9 @@ li.hide + li.version .badge .tooltip .popover-arrow { background-color: rgba(0,0,0,.05); } } +body { + scrollbar-width: 10px; +} /* Intro walkthrough diff --git a/data/core.yaml b/data/core.yaml index 6320908e5..4eb68fd64 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1442,6 +1442,17 @@ en: tooltip: "Street-level photos from Mapilio" street_side: minzoom_tooltip: "Zoom in to see street-side photos" + local_photos: + tooltip: Add georeferenced photos from local files + tooltip_edit: Edit georeferenced photos + header: Georeferenced Photos + zoom: Zoom to photos + zoom_single: Zoom to photo + file: + instructions: "Choose georeferenced photos to be displayed. Supported types are .jpg and .png with exif location data" + label: "Browse files" + no_geolocation: + tooltip: Image without geolocation cannot be located on the map note: note: Note title: Edit note diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index f654ef1ea..980d01c22 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -12,7 +12,7 @@ let _widthOverflow; function zoomPan (d3_event) { let t = d3_event.transform; _photo.call(utilSetTransform, t.x, t.y, t.k); - } +} function zoomBeahvior () { const {width: wrapperWidth, height: wrapperHeight} = _wrapper.node().getBoundingClientRect(); @@ -58,7 +58,7 @@ export default { await Promise.resolve(); return this; - }, + }, showPhotoFrame: function (context) { const isHidden = context.selectAll('.photo-frame.plane-frame.hide').size(); @@ -74,7 +74,7 @@ export default { } return this; - }, + }, hidePhotoFrame: function (context) { @@ -83,7 +83,7 @@ export default { .classed('hide', false); return this; - }, + }, selectPhoto: function (data, keepOrientation) { dispatch.call('viewerChanged'); diff --git a/modules/services/vegbilder.js b/modules/services/vegbilder.js index 9735fd25b..fe6f506e7 100644 --- a/modules/services/vegbilder.js +++ b/modules/services/vegbilder.js @@ -500,19 +500,19 @@ export default { }, showViewer: function (context) { - const viewer = context.container().select('.photoviewer') - .classed('hide', false); - - const isHidden = viewer.selectAll('.photo-wrapper.vegbilder-wrapper.hide').size(); - - if (isHidden) { - viewer - .selectAll('.photo-wrapper:not(.vegbilder-wrapper)') - .classed('hide', true); - - viewer - .selectAll('.photo-wrapper.vegbilder-wrapper') + const viewer = context.container().select('.photoviewer') .classed('hide', false); + + const isHidden = viewer.selectAll('.photo-wrapper.vegbilder-wrapper.hide').size(); + + if (isHidden) { + viewer + .selectAll('.photo-wrapper:not(.vegbilder-wrapper)') + .classed('hide', true); + + viewer + .selectAll('.photo-wrapper.vegbilder-wrapper') + .classed('hide', false); } return this; }, @@ -532,7 +532,7 @@ export default { .classed('currentView', false); return this.setStyles(context, null, true); -}, + }, // Updates the currently highlighted sequence and selected bubble. diff --git a/modules/svg/data.js b/modules/svg/data.js index a88e4b0ff..9bff20755 100644 --- a/modules/svg/data.js +++ b/modules/svg/data.js @@ -29,6 +29,13 @@ export function svgData(projection, context, dispatch) { var _template; var _src; + const supportedFormats = [ + '.gpx', + '.kml', + '.geojson', + '.json' + ]; + function init() { if (_initialized) return; // run once @@ -48,6 +55,9 @@ export function svgData(projection, context, dispatch) { d3_event.stopPropagation(); d3_event.preventDefault(); if (!detected.filedrop) return; + var f = d3_event.dataTransfer.files[0]; + var extension = getExtension(f.name); + if (!supportedFormats.includes(extension)) return; drawData.fileList(d3_event.dataTransfer.files); }) .on('dragenter.svgData', over) @@ -304,7 +314,7 @@ export function svgData(projection, context, dispatch) { function getExtension(fileName) { if (!fileName) return; - var re = /\.(gpx|kml|(geo)?json)$/i; + var re = /\.(gpx|kml|(geo)?json|png)$/i; var match = fileName.toLowerCase().match(re); return match && match.length && match[0]; } @@ -457,9 +467,9 @@ export function svgData(projection, context, dispatch) { if (!arguments.length) return _fileList; _template = null; - _fileList = fileList; _geojson = null; _src = null; + _fileList = fileList; if (!fileList || !fileList.length) return this; var f = fileList[0]; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 36f6d767e..b213334d4 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -2,6 +2,7 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { svgData } from './data'; +import { svgLocalPhotos} from './local_photos'; import { svgDebug } from './debug'; import { svgGeolocate } from './geolocate'; import { svgKeepRight } from './keepRight'; @@ -40,6 +41,7 @@ export function svgLayers(projection, context) { { id: 'kartaview', layer: svgKartaviewImages(projection, context, dispatch) }, { id: 'mapilio', layer: svgMapilioImages(projection, context, dispatch) }, { id: 'vegbilder', layer: svgVegbilder(projection, context, dispatch) }, + { id: 'local-photos', layer: svgLocalPhotos(projection, context, dispatch) }, { id: 'debug', layer: svgDebug(projection, context, dispatch) }, { id: 'geolocate', layer: svgGeolocate(projection, context, dispatch) }, { id: 'touch', layer: svgTouch(projection, context, dispatch) }, diff --git a/modules/svg/local_photos.js b/modules/svg/local_photos.js new file mode 100644 index 000000000..dd8681c1c --- /dev/null +++ b/modules/svg/local_photos.js @@ -0,0 +1,353 @@ +import { select as d3_select } from 'd3-selection'; +import exifr from 'exifr'; +import { isArray, isNumber } from 'lodash-es'; + +import { utilDetect } from '../util/detect'; +import { geoExtent, geoPolygonIntersectsPolygon } from '../geo'; +import planePhotoFrame from '../services/plane_photo'; + +var _initialized = false; +var _enabled = false; +const minViewfieldZoom = 16; + +export function svgLocalPhotos(projection, context, dispatch) { + const detected = utilDetect(); + let layer = d3_select(null); + let _fileList; + let _photos = []; + let _idAutoinc = 0; + let _photoFrame; + + function init() { + if (_initialized) return; // run once + + _enabled = true; + + function over(d3_event) { + d3_event.stopPropagation(); + d3_event.preventDefault(); + d3_event.dataTransfer.dropEffect = 'copy'; + } + + context.container() + .attr('dropzone', 'copy') + .on('drop.svgLocalPhotos', function(d3_event) { + d3_event.stopPropagation(); + d3_event.preventDefault(); + if (!detected.filedrop) return; + drawPhotos.fileList(d3_event.dataTransfer.files, loaded => { + if (loaded.length > 0) { + drawPhotos.fitZoom(); + } + }); + }) + .on('dragenter.svgLocalPhotos', over) + .on('dragexit.svgLocalPhotos', over) + .on('dragover.svgLocalPhotos', over); + + _initialized = true; + } + + function ensureViewerLoaded(context) { + if (_photoFrame) { + return Promise.resolve(_photoFrame); + } + + const viewer = context.container().select('.photoviewer') + .selectAll('.local-photos-wrapper') + .data([0]); + + const viewerEnter = viewer.enter() + .append('div') + .attr('class', 'photo-wrapper local-photos-wrapper') + .classed('hide', true); + + viewerEnter + .append('div') + .attr('class', 'photo-attribution fillD'); + + return planePhotoFrame.init(context, viewerEnter) + .then(planePhotoFrame => { + _photoFrame = planePhotoFrame; + }); + } + + // opens the image at bottom left + function click(d3_event, image, zoomTo) { + ensureViewerLoaded(context).then(() => { + const viewer = context.container().select('.photoviewer') + .datum(image) + .classed('hide', false); + + const viewerWrap = viewer.select('.local-photos-wrapper') + .classed('hide', false); + + const attribution = viewerWrap.selectAll('.photo-attribution').text(''); + + if (image.name) { + attribution + .append('span') + .classed('filename', true) + .text(image.name); + } + + _photoFrame.selectPhoto({ image_path: '' }); + image.getSrc().then(src => { + _photoFrame + .selectPhoto({ image_path: src }) + .showPhotoFrame(viewerWrap); + }); + }); + + // centers the map with image location + if (zoomTo) { + context.map().centerEase(image.loc); + } + } + + + function transform(d) { + // projection expects [long, lat] + var svgpoint = projection(d.loc); + return 'translate(' + svgpoint[0] + ',' + svgpoint[1] + ')'; + } + + function setStyles(hovered) { + const viewer = context.container().select('.photoviewer'); + const selected = viewer.empty() ? undefined : viewer.datum(); + + context.container().selectAll('.layer-local-photos .viewfield-group') + .classed('hovered', d => d.id === hovered?.id) + .classed('highlighted', d => d.id === hovered?.id || d.id === selected?.id) + .classed('currentView', d => d.id === selected?.id); + } + + // puts the image markers on the map + function display_markers(imageList) { + imageList = imageList.filter(image => isArray(image.loc) && isNumber(image.loc[0]) && isNumber(image.loc[1])); + const groups = layer.selectAll('.markers').selectAll('.viewfield-group') + .data(imageList, function(d) { return d.id; }); + + // exit + groups.exit() + .remove(); + + // enter + const groupsEnter = groups.enter() + .append('g') + .attr('class', 'viewfield-group') + .on('mouseenter', (d3_event, d) => setStyles(d)) + .on('mouseleave', () => setStyles(null)) + .on('click', click); + + groupsEnter + .append('g') + .attr('class', 'viewfield-scale'); + + // update + const markers = groups + .merge(groupsEnter) + .attr('transform', transform) + .select('.viewfield-scale'); + + + markers.selectAll('circle') + .data([0]) + .enter() + .append('circle') + .attr('dx', '0') + .attr('dy', '0') + .attr('r', '6'); + + const showViewfields = context.map().zoom() >= minViewfieldZoom; + + const viewfields = markers.selectAll('.viewfield') + .data(showViewfields ? [0] : []); + + viewfields.exit() + .remove(); + + // viewfields may or may not be drawn... + // but if they are, draw below the circles + viewfields.enter() + .insert('path', 'circle') + .attr('class', 'viewfield') + .attr('transform', function() { + const d = this.parentNode.__data__; + return `rotate(${Math.round(d.direction ?? 0)},0,0),scale(1.5,1.5),translate(-8,-13)`; + }) + .attr('d', 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z') + .style('visibility', function() { + const d = this.parentNode.__data__; + return isNumber(d.direction) ? 'visible' : 'hidden'; + }); + } + + function drawPhotos(selection) { + layer = selection.selectAll('.layer-local-photos') + .data(_photos ? [0] : []); + + layer.exit() + .remove(); + + const layerEnter = layer.enter() + .append('g') + .attr('class', 'layer-local-photos'); + + layerEnter + .append('g') + .attr('class', 'markers'); + + layer = layerEnter + .merge(layer); + + if (_photos && _photos.length !== 0) { + display_markers(_photos); + } + } + + + function readFileAsDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = error => reject(error); + reader.readAsDataURL(file); + }); + } + /** + * Reads and parses files + * @param {Array} files - Holds array of file - [file_1, file_2, ...] + */ + async function readmultifiles(files, callback) { + const loaded = []; + + for (const file of files) { + try { + const exifData = await exifr.parse(file); // eslint-disable-line no-await-in-loop + const photo = { + id: _idAutoinc++, + name: file.name, + getSrc: () => readFileAsDataURL(file), + file: file, + loc: [exifData.longitude, exifData.latitude], + direction: exifData.GPSImgDirection + }; + loaded.push(photo); + const sameName = _photos.filter(i => i.name === photo.name); + if (sameName.length === 0) { + _photos.push(photo); + } else { + const thisContent = await photo.getSrc(); // eslint-disable-line no-await-in-loop + const sameNameContent = await Promise.allSettled(sameName.map(i => i.getSrc())); // eslint-disable-line no-await-in-loop + if (!sameNameContent.some(i => i.value === thisContent)) { + _photos.push(photo); + } + } + } catch (err) { + // skip files which are not a supported image file + } + } + + if (typeof callback === 'function') callback(loaded); + dispatch.call('change'); + } + + drawPhotos.setFiles = function(fileList, callback) { + // read and parse asynchronously + readmultifiles(Array.from(fileList), callback); + return this; + }; + + // Step 1: entry point + /** + * Sets the fileList + * @param {Object} fileList - The uploaded files. fileList is an object, not an array object + * @param {Object} fileList.0 - A File - {name: "Das.png", lastModified: 1625064498536, lastModifiedDate: Wed Jun 30 2021 20:18:18 GMT+0530 (India Standard Time), webkitRelativePath: "", size: 859658, …} + * @param {Function} callback - A callback to be called after the photos have been loaded and parsed + */ + drawPhotos.fileList = function(fileList, callback) { + if (!arguments.length) return _fileList; + + _fileList = fileList; + + if (!fileList || !fileList.length) return this; + + drawPhotos.setFiles(_fileList, callback); + + return this; + }; + + drawPhotos.getPhotos = function() { + return _photos; + }; + + drawPhotos.removePhoto = function(id) { + _photos = _photos.filter(i => i.id !== id); + dispatch.call('change'); + return _photos; + }; + + drawPhotos.openPhoto = click; + + drawPhotos.fitZoom = function() { + const coords = _photos + .map(image => image.loc); + const extent = coords + .filter(l => isArray(l) && isNumber(l[0]) && isNumber(l[1])) + .map(l => geoExtent(l, l)) + .reduce((a, b) => a.extend(b)); + + const map = context.map(); + var viewport = map.trimmedExtent().polygon(); + + if (!geoPolygonIntersectsPolygon(viewport, coords, true)) { + map.centerZoom(extent.center(), Math.min(18, map.trimmedExtentZoom(extent))); + } + }; + + function showLayer() { + layer.style('display', 'block'); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', function () { dispatch.call('change'); }); + } + + + function hideLayer() { + layer + .transition() + .duration(250) + .style('opacity', 0) + .on('end', () => { + layer.selectAll('.viewfield-group').remove(); + layer.style('display', 'none'); + }); + } + + drawPhotos.enabled = function(val) { + if (!arguments.length) return _enabled; + + _enabled = val; + if (_enabled) { + showLayer(); + } else { + hideLayer(); + } + + dispatch.call('change'); + return this; + }; + + drawPhotos.hasData = function() { + return isArray(_photos) && _photos.length > 0; + }; + + + init(); + return drawPhotos; +} diff --git a/modules/svg/mapillary_images.js b/modules/svg/mapillary_images.js index 6afba1c05..b000b7534 100644 --- a/modules/svg/mapillary_images.js +++ b/modules/svg/mapillary_images.js @@ -164,7 +164,6 @@ export function svgMapillaryImages(projection, context, dispatch) { } function update() { - const z = ~~context.map().zoom(); const showMarkers = (z >= minMarkerZoom); const showViewfields = (z >= minViewfieldZoom); @@ -172,6 +171,15 @@ export function svgMapillaryImages(projection, context, dispatch) { const service = getService(); let sequences = (service ? service.sequences(projection) : []); let images = (service && showMarkers ? service.images(projection) : []); + // images[0] + // { + // "loc":[13.235349655151367,52.50694232952122], + // "captured_at":1619457514500, + // "ca":0, + // "id":505488307476058, + // "is_pano":false, + // "sequence_id":"zcyumxorbza3dq3twjybam" + // } images = filterImages(images); sequences = filterSequences(sequences, service); diff --git a/modules/ui/sections/data_layers.js b/modules/ui/sections/data_layers.js index c8f469d29..41afb19f9 100644 --- a/modules/ui/sections/data_layers.js +++ b/modules/ui/sections/data_layers.js @@ -18,6 +18,7 @@ export function uiSectionDataLayers(context) { var settingsCustomData = uiSettingsCustomData(context) .on('change', customChanged); + // refers to `modules/svg/layers.js` -> function drawLayers(selection) {...} var layers = context.layers(); var section = uiSection('data-layers', context) @@ -386,7 +387,6 @@ export function uiSectionDataLayers(context) { } } - function drawPanelItems(selection) { var panelsListEnter = selection.selectAll('.md-extras-list') diff --git a/modules/ui/sections/photo_overlays.js b/modules/ui/sections/photo_overlays.js index b9a9b5876..fe76d6dbe 100644 --- a/modules/ui/sections/photo_overlays.js +++ b/modules/ui/sections/photo_overlays.js @@ -1,15 +1,18 @@ import _debounce from 'lodash-es/debounce'; -import { - select as d3_select -} from 'd3-selection'; +import { select as d3_select } from 'd3-selection'; -import { t } from '../../core/localizer'; +import { localizer, t } from '../../core/localizer'; import { uiTooltip } from '../tooltip'; import { uiSection } from '../section'; import { utilGetSetValue, utilNoAuto } from '../../util'; +import { uiSettingsLocalPhotos } from '../settings/local_photos'; +import { svgIcon } from '../../svg'; export function uiSectionPhotoOverlays(context) { + var settingsLocalPhotos = uiSettingsLocalPhotos(context) + .on('change', localPhotosChanged); + var layers = context.layers(); var section = uiSection('photo-overlays', context) @@ -28,7 +31,8 @@ export function uiSectionPhotoOverlays(context) { .call(drawPhotoItems) .call(drawPhotoTypeItems) .call(drawDateFilter) - .call(drawUsernameFilter); + .call(drawUsernameFilter) + .call(drawLocalPhotos); } function drawPhotoItems(selection) { @@ -335,6 +339,96 @@ export function uiSectionPhotoOverlays(context) { } } + function drawLocalPhotos(selection) { + var photoLayer = layers.layer('local-photos'); + var hasData = photoLayer && photoLayer.hasData(); + var showsData = hasData && photoLayer.enabled(); + + var ul = selection + .selectAll('.layer-list-local-photos') + .data(photoLayer ? [0] : []); + + // Exit + ul.exit() + .remove(); + + // Enter + var ulEnter = ul.enter() + .append('ul') + .attr('class', 'layer-list layer-list-local-photos'); + + var localPhotosEnter = ulEnter + .append('li') + .attr('class', 'list-item-local-photos'); + + var localPhotosLabelEnter = localPhotosEnter + .append('label') + .call(uiTooltip().title(() => t.append('local_photos.tooltip'))); + + localPhotosLabelEnter + .append('input') + .attr('type', 'checkbox') + .on('change', function() { toggleLayer('local-photos'); }); + + localPhotosLabelEnter + .call(t.append('local_photos.header')); + + localPhotosEnter + .append('button') + .attr('class', 'open-data-options') + .call(uiTooltip() + .title(() => t.append('local_photos.tooltip_edit')) + .placement((localizer.textDirection() === 'rtl') ? 'right' : 'left') + ) + .on('click', function(d3_event) { + d3_event.preventDefault(); + editLocalPhotos(); + }) + .call(svgIcon('#iD-icon-more')); + + localPhotosEnter + .append('button') + .attr('class', 'zoom-to-data') + .call(uiTooltip() + .title(() => t.append('local_photos.zoom')) + .placement((localizer.textDirection() === 'rtl') ? 'right' : 'left') + ) + .on('click', function(d3_event) { + if (d3_select(this).classed('disabled')) return; + + d3_event.preventDefault(); + d3_event.stopPropagation(); + photoLayer.fitZoom(); + }) + .call(svgIcon('#iD-icon-framed-dot', 'monochrome')); + + // Update + ul = ul + .merge(ulEnter); + + ul.selectAll('.list-item-local-photos') + .classed('active', showsData) + .selectAll('label') + .classed('deemphasize', !hasData) + .selectAll('input') + .property('disabled', !hasData) + .property('checked', showsData); + + ul.selectAll('button.zoom-to-data') + .classed('disabled', !hasData); + } + + function editLocalPhotos() { + context.container() + .call(settingsLocalPhotos); + } + + function localPhotosChanged(d) { + var localPhotosLayer = layers.layer('local-photos'); + + localPhotosLayer.fileList(d); + } + context.layers().on('change.uiSectionPhotoOverlays', section.reRender); context.photos().on('change.uiSectionPhotoOverlays', section.reRender); diff --git a/modules/ui/sections/raw_tag_editor.js b/modules/ui/sections/raw_tag_editor.js index 43bd043d5..52d98b668 100644 --- a/modules/ui/sections/raw_tag_editor.js +++ b/modules/ui/sections/raw_tag_editor.js @@ -292,7 +292,11 @@ export function uiSectionRawTagEditor(id, context) { }); items.selectAll('button.remove') - .on(('PointerEvent' in window ? 'pointer' : 'mouse') + 'down', removeTag); // 'click' fires too late - #5878 + .on(('PointerEvent' in window ? 'pointer' : 'mouse') + 'down', // 'click' fires too late - #5878 + (d3_event, d) => { + if (d3_event.button !== 0) return; + removeTag(d3_event, d); + }); } diff --git a/modules/ui/settings/custom_data.js b/modules/ui/settings/custom_data.js index 29a1d83d2..2ad991fc4 100644 --- a/modules/ui/settings/custom_data.js +++ b/modules/ui/settings/custom_data.js @@ -19,7 +19,7 @@ export function uiSettingsCustomData(context) { }; var _currSettings = { fileList: (dataLayer && dataLayer.fileList()) || null, - url: prefs('settings-custom-data-url') + // url: prefs('settings-custom-data-url') }; // var example = 'https://{switch:a,b,c}.tile.openstreetmap.org/{zoom}/{x}/{y}.png'; diff --git a/modules/ui/settings/local_photos.js b/modules/ui/settings/local_photos.js new file mode 100644 index 000000000..1d580e5f1 --- /dev/null +++ b/modules/ui/settings/local_photos.js @@ -0,0 +1,139 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { isArray, isNumber } from 'lodash-es'; + +import { t } from '../../core/localizer'; +import { uiConfirm } from '../confirm'; +import { utilRebind } from '../../util'; +import { uiTooltip } from '../tooltip'; +import { svgIcon } from '../../svg'; + + +export function uiSettingsLocalPhotos(context) { + var dispatch = d3_dispatch('change'); + var photoLayer = context.layers().layer('local-photos'); + var modal; + + function render(selection) { + + modal = uiConfirm(selection).okButton(); + + modal + .classed('settings-modal settings-local-photos', true); + + modal.select('.modal-section.header') + .append('h3') + .call(t.append('local_photos.header')); + + modal.select('.modal-section.message-text') + .append('div') + .classed('local-photos', true); + + var instructionsSection = modal.select('.modal-section.message-text .local-photos') + .append('div') + .classed('instructions', true); + + instructionsSection + .append('p') + .classed('instructions-local-photos', true) + .call(t.append('local_photos.file.instructions')); + + instructionsSection + .append('input') + .classed('field-file', true) + .attr('type', 'file') + .attr('multiple', 'multiple') + .attr('accept', '.jpg,.jpeg,.png,image/png,image/jpeg') + .style('visibility', 'hidden') + .attr('id', 'local-photo-files') + .on('change', function(d3_event) { + var files = d3_event.target.files; + if (files && files.length) { + photoList + .select('ul') + .append('li') + .classed('placeholder', true) + .append('div'); + dispatch.call('change', this, files); + } + d3_event.target.value = null; + }); + instructionsSection + .append('label') + .attr('for', 'local-photo-files') + .classed('button', true) + .call(t.append('local_photos.file.label')); + + const photoList = modal.select('.modal-section.message-text .local-photos') + .append('div') + .append('div') + .classed('list-local-photos', true); + + photoList + .append('ul'); + + updatePhotoList(photoList.select('ul')); + + context.layers().on('change', () => updatePhotoList(photoList.select('ul'))); + } + + function updatePhotoList(container) { + function locationUnavailable(d) { + return !(isArray(d.loc) && isNumber(d.loc[0]) && isNumber(d.loc[1])); + } + + container.selectAll('li.placeholder').remove(); + + let selection = container.selectAll('li') + .data(photoLayer.getPhotos() ?? [], d => d.id); + selection.exit() + .remove(); + + const selectionEnter = selection.enter() + .append('li'); + + selectionEnter + .append('span') + .classed('filename', true); + selectionEnter + .append('button') + .classed('form-field-button zoom-to-data', true) + .attr('title', t('local_photos.zoom_single')) + .call(svgIcon('#iD-icon-framed-dot')); + selectionEnter + .append('button') + .classed('form-field-button no-geolocation', true) + .call(svgIcon('#iD-icon-alert')) + .call(uiTooltip() + .title(() => t.append('local_photos.no_geolocation.tooltip')) + .placement('left') + ); + selectionEnter + .append('button') + .classed('form-field-button remove', true) + .attr('title', t('icons.remove')) + .call(svgIcon('#iD-operation-delete')); + + selection = selection.merge(selectionEnter); + + selection + .classed('invalid', locationUnavailable); + selection.select('span.filename') + .text(d => d.name) + .attr('title', d => d.name); + selection.select('span.filename') + .on('click', (d3_event, d) => { + photoLayer.openPhoto(d3_event, d, false); + }); + selection.select('button.zoom-to-data') + .on('click', (d3_event, d) => { + photoLayer.openPhoto(d3_event, d, true); + }); + selection.select('button.remove') + .on('click', (d3_event, d) => { + photoLayer.removePhoto(d.id); + updatePhotoList(container); + }); + } + + return utilRebind(render, dispatch, 'on'); +} diff --git a/modules/util/trigger_event.js b/modules/util/trigger_event.js index 2639d60de..817fea141 100644 --- a/modules/util/trigger_event.js +++ b/modules/util/trigger_event.js @@ -1,7 +1,10 @@ -export function utilTriggerEvent(target, type) { +export function utilTriggerEvent(target, type, eventProperties) { target.each(function() { var evt = document.createEvent('HTMLEvents'); evt.initEvent(type, true, true); + for (var prop in eventProperties) { + evt[prop] = eventProperties[prop]; + } this.dispatchEvent(evt); }); } diff --git a/package-lock.json b/package-lock.json index 0dbfeeb3a..cfac609f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "alif-toolkit": "^1.2.9", "core-js-bundle": "^3.32.0", "diacritics": "1.3.0", + "exifr": "^7.1.3", "fast-deep-equal": "~3.1.1", "fast-json-stable-stringify": "2.1.0", "lodash-es": "~4.17.15", @@ -4053,6 +4054,11 @@ "safe-buffer": "^5.1.1" } }, + "node_modules/exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" + }, "node_modules/extend": { "version": "3.0.2", "dev": true, @@ -12741,6 +12747,11 @@ "safe-buffer": "^5.1.1" } }, + "exifr": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz", + "integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==" + }, "extend": { "version": "3.0.2", "dev": true diff --git a/package.json b/package.json index 1f50a0a76..ccde47a41 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "alif-toolkit": "^1.2.9", "core-js-bundle": "^3.32.0", "diacritics": "1.3.0", + "exifr": "^7.1.3", "fast-deep-equal": "~3.1.1", "fast-json-stable-stringify": "2.1.0", "lodash-es": "~4.17.15", diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index 0bc48daf9..6440d8b2b 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,7 +26,7 @@ describe('iD.svgLayers', function () { it('creates default data layers', function () { container.call(iD.svgLayers(projection, context)); var nodes = container.selectAll('svg .data-layer').nodes(); - expect(nodes.length).to.eql(17); + expect(nodes.length).to.eql(18); expect(d3.select(nodes[0]).classed('osm')).to.be.true; expect(d3.select(nodes[1]).classed('notes')).to.be.true; expect(d3.select(nodes[2]).classed('data')).to.be.true; @@ -41,9 +41,10 @@ describe('iD.svgLayers', function () { expect(d3.select(nodes[11]).classed('kartaview')).to.be.true; expect(d3.select(nodes[12]).classed('mapilio')).to.be.true; expect(d3.select(nodes[13]).classed('vegbilder')).to.be.true; - expect(d3.select(nodes[14]).classed('debug')).to.be.true; - expect(d3.select(nodes[15]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[16]).classed('touch')).to.be.true; + expect(d3.select(nodes[14]).classed('local-photos')).to.be.true; + expect(d3.select(nodes[15]).classed('debug')).to.be.true; + expect(d3.select(nodes[16]).classed('geolocate')).to.be.true; + expect(d3.select(nodes[17]).classed('touch')).to.be.true; }); }); diff --git a/test/spec/ui/sections/raw_tag_editor.js b/test/spec/ui/sections/raw_tag_editor.js index 5a9c9466e..e34c4f575 100644 --- a/test/spec/ui/sections/raw_tag_editor.js +++ b/test/spec/ui/sections/raw_tag_editor.js @@ -53,7 +53,7 @@ describe('iD.uiSectionRawTagEditor', function() { expect(tags).to.eql({highway: undefined}); done(); }); - iD.utilTriggerEvent(element.selectAll('button.remove'), 'mousedown'); + iD.utilTriggerEvent(element.selectAll('button.remove'), 'mousedown', { button: 0 }); }); it('adds tags when pressing the TAB key on last input.value', function (done) {