From d1e5c2910c7b118f677ab24211c3054f52fbd404 Mon Sep 17 00:00:00 2001 From: Mattia Pezzotti <45800507+mattiapezzotti@users.noreply.github.com> Date: Tue, 18 Mar 2025 21:09:37 +0100 Subject: [PATCH] add date slider for street level photos, and more (#10394) full list of enhancements: * year slider to filter photos by freshness * toggle active streetlevel layers with shortcut `shift+P` * hfov, pitch and direction is now held between sequences and images as asked in #10392 * fix for #10361 (only panoramax) * added tests and jsdoc * add ticks for existing photos on slider * general bug fixes * rudimentary support for toDate in date slider (only when iD is started with a "to" date in the hash parameter: show a second slider to visualize and set the "to" date) --------- Co-authored-by: Martin Raifer --- CHANGELOG.md | 8 +- css/60_photos.css | 36 +++- data/core.yaml | 7 + data/shortcuts.json | 5 + modules/renderer/photos.js | 87 ++++++++-- modules/services/pannellum_photo.js | 30 +++- modules/services/panoramax.js | 166 ++++++++++++------ modules/services/plane_photo.js | 57 ++++--- modules/svg/kartaview_images.js | 26 +-- modules/svg/layers.js | 2 +- modules/svg/local_photos.js | 3 +- modules/svg/mapilio_images.js | 49 ++++++ modules/svg/mapillary_images.js | 4 + modules/svg/panoramax_images.js | 60 +++++-- modules/svg/streetside.js | 5 + modules/svg/vegbilder.js | 3 + modules/ui/sections/photo_overlays.js | 234 +++++++++++++++++++++----- test/spec/services/panoramax.js | 86 +++++++++- 18 files changed, 698 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8398c206..be321207e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,9 @@ _Breaking developer changes, which may affect downstream projects or sites that #### :scissors: Operations * Fix unexpected behavior of squaring operation on individual vertices ([#10401]) #### :camera: Street-Level +* Replace date filter input boxes with a slider to select photos by freshness ([#10394], thanks [@mattiapezzotti]) +* Preserve relative viewing direction when switching between 360° panoramas in Panoramax([#10392], thanks [@mattiapezzotti]) +* Add keyboard shortcut `Shift + P` to toggle active street level photo layers ([#10394], thanks [@mattiapezzotti])) * Add prev/next button to viewer for local georeferenced photos ([#10852], thanks [@0xatulpatil]) #### :white_check_mark: Validation * The Suspicious Names validator warning now also compares the Name field to the preset’s aliases (in addition to the preset’s name) in the user’s language @@ -58,15 +61,18 @@ _Breaking developer changes, which may affect downstream projects or sites that * Take location into account when setting a presets default values from regional fields #### :hammer: Development -[#10805]: https://github.com/openstreetmap/iD/pull/10805 [#10299]: https://github.com/openstreetmap/iD/issues/10299 +[#10392]: https://github.com/openstreetmap/iD/issues/10392 +[#10394]: https://github.com/openstreetmap/iD/pull/10394 [#10401]: https://github.com/openstreetmap/iD/issues/10401 +[#10805]: https://github.com/openstreetmap/iD/pull/10805 [#10843]: https://github.com/openstreetmap/iD/pull/10843 [#10852]: https://github.com/openstreetmap/iD/issues/10852 [#10885]: https://github.com/openstreetmap/iD/issues/10885 [@0xatulpatil]: https://github.com/0xatulpatil + # 2.32.0 ##### 2025-03-05 diff --git a/css/60_photos.css b/css/60_photos.css index 22cba8377..d09870198 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -12,6 +12,11 @@ li.list-item-photos.active:after { left: 0; } +.disabled-panel { + pointer-events: none; + opacity: 0.5; +} + /* photo viewer div */ .photoviewer { position: relative; @@ -387,15 +392,25 @@ label.panoramax-hd { .slider-wrap { display: inline-block; + width: 100%; } -.year-datalist { +.date-slider-label { display: flex; justify-content: space-between; } -.list-option-date-slider{ - direction: rtl +.list-option-date-slider { + width: 100%; +} + +.yearSliderSpan{ + padding: 2px; +} + + +.list-item-date-slider label{ + display: block !important; } /* Streetside Viewer (pannellum) */ @@ -521,6 +536,16 @@ label.streetside-hires { transform-origin: 0 0; } +.panoramax-wrapper .photo-attribution a:active { + color: #ff6f00; +} + +@media (hover: hover) { + .panoramax-wrapper .photo-attribution a:hover { + color: #ff6f00; + } +} + .photo-wrapper { position: relative; background-color: #000; @@ -536,9 +561,10 @@ label.streetside-hires { } .photoviewer .plane-frame > img.plane-photo { - width: auto; + width: 100%; height: 100%; - transform-origin: 0 0; + overflow: hidden; + object-fit: cover; } /* photo-controls (step forward, back, rotate) */ diff --git a/data/core.yaml b/data/core.yaml index 01e4e1d04..2a8ef60d7 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -905,6 +905,11 @@ en: username_filter: title: "Username" tooltip: "Show only photos by this user" + age_slider_filter: + title: "Date Range" + tooltip: "Shows only photos that are newer than the selected date" + label_all: "All photos visible." + label_date: "Showing photos newer than {date}." feature: points: description: Points @@ -2418,6 +2423,8 @@ en: way_selected: title: "With way selected" child: "Select child nodes" + enable: + streetlevel: "Toggle active street level photo layers" editing: title: "Editing" drawing: diff --git a/data/shortcuts.json b/data/shortcuts.json index f5402fd72..023369312 100644 --- a/data/shortcuts.json +++ b/data/shortcuts.json @@ -74,6 +74,11 @@ "shortcuts": ["area_fill.wireframe.key"], "text": "shortcuts.browsing.display_options.osm_data" }, + { + "modifiers": ["⇧"], + "shortcuts": ["P"], + "text": "shortcuts.browsing.enable.streetlevel" + }, { "shortcuts": ["background.minimap.key"], "text": "shortcuts.browsing.display_options.minimap" diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index b28fd9b2f..fb27d646b 100644 --- a/modules/renderer/photos.js +++ b/modules/renderer/photos.js @@ -34,14 +34,23 @@ export function rendererPhotos(context) { window.location.replace('#' + utilQsString(hash, true)); } + /** + * @returns The layer ID + */ photos.overlayLayerIDs = function() { return _layerIDs; }; + /** + * @returns All the photo types + */ photos.allPhotoTypes = function() { return _allPhotoTypes; }; + /** + * @returns The date filters value + */ photos.dateFilters = function() { return _dateFilters; }; @@ -50,6 +59,12 @@ export function rendererPhotos(context) { return val === _dateFilters[0] ? _fromDate : _toDate; }; + /** + * Sets the date filter (min/max date) + * @param {*} type Either 'fromDate' or 'toDate' + * @param {*} val The actual Date + * @param {boolean} updateUrl Whether the URL should update or not + */ photos.setDateFilter = function(type, val, updateUrl) { // validate the date var date = val && new Date(val); @@ -80,6 +95,11 @@ export function rendererPhotos(context) { } }; + /** + * Sets the username filter + * @param {string} val The username + * @param {boolean} updateUrl Whether the URL should update or not + */ photos.setUsernameFilter = function(val, updateUrl) { if (val && typeof val === 'string') val = val.replace(/;/g, ',').split(','); if (val) { @@ -99,6 +119,36 @@ export function rendererPhotos(context) { } }; + /** + * Util function to set the slider date filter + * @param {*} val Either 'panoramic' or 'flat' + * @param {boolean} updateUrl Whether the URL should update or not + */ + photos.togglePhotoType = function(val, updateUrl) { + var index = _shownPhotoTypes.indexOf(val); + if (index !== -1) { + _shownPhotoTypes.splice(index, 1); + } else { + _shownPhotoTypes.push(val); + } + + if (updateUrl) { + var hashString; + if (_shownPhotoTypes) { + hashString = _shownPhotoTypes.join(','); + } + setUrlFilterValue('photo_type', hashString); + } + + dispatch.call('change', this); + return photos; + }; + + /** + * Updates the URL with new values + * @param {*} val value to save + * @param {string} property Name of the value + */ function setUrlFilterValue(property, val) { if (!window.mocha) { var hash = utilStringQs(window.location.hash); @@ -118,15 +168,25 @@ export function rendererPhotos(context) { return layer && layer.supported() && layer.enabled(); } - photos.shouldFilterByDate = function() { - return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside') || showsLayer('vegbilder') || showsLayer('panoramax'); + /** + * @returns If the Date Slider filter should be drawn + */ + photos.shouldFilterDateBySlider = function(){ + return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('mapilio') + || showsLayer('streetside') || showsLayer('vegbilder') || showsLayer('panoramax'); }; + /** + * @returns If the Photo Type filter should be drawn + */ photos.shouldFilterByPhotoType = function() { return showsLayer('mapillary') || (showsLayer('streetside') && showsLayer('kartaview')) || showsLayer('vegbilder') || showsLayer('panoramax'); }; + /** + * @returns If the Username filter should be drawn + */ photos.shouldFilterByUsername = function() { return !showsLayer('mapillary') && showsLayer('kartaview') && !showsLayer('streetside') || showsLayer('panoramax'); }; @@ -153,32 +213,31 @@ export function rendererPhotos(context) { return _toDate; }; - photos.togglePhotoType = function(val) { - var index = _shownPhotoTypes.indexOf(val); - if (index !== -1) { - _shownPhotoTypes.splice(index, 1); - } else { - _shownPhotoTypes.push(val); - } - dispatch.call('change', this); - return photos; - }; - photos.usernames = function() { return _usernames; }; + /** + * Inits the streetlevel layer given the saved values in the URL + */ photos.init = function() { var hash = utilStringQs(window.location.hash); + var parts; if (hash.photo_dates) { // expect format like `photo_dates=2019-01-01_2020-12-31`, but allow a couple different separators - var parts = /^(.*)[–_](.*)$/g.exec(hash.photo_dates.trim()); + parts = /^(.*)[–_](.*)$/g.exec(hash.photo_dates.trim()); this.setDateFilter('fromDate', parts && parts.length >= 2 && parts[1], false); this.setDateFilter('toDate', parts && parts.length >= 3 && parts[2], false); } if (hash.photo_username) { this.setUsernameFilter(hash.photo_username, false); } + if (hash.photo_type) { + parts = hash.photo_type.replace(/;/g, ',').split(','); + _allPhotoTypes.forEach(d => { + if (!parts.includes(d)) this.togglePhotoType(d, false); + }); + } if (hash.photo_overlay) { // support enabling photo layers by default via a URL parameter, e.g. `photo_overlay=kartaview;mapillary;streetside` var hashOverlayIDs = hash.photo_overlay.replace(/;/g, ',').split(','); diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js index 566358edd..914773ce8 100644 --- a/modules/services/pannellum_photo.js +++ b/modules/services/pannellum_photo.js @@ -19,6 +19,7 @@ export default { .attr('class', 'photo-frame pannellum-frame') .attr('id', 'ideditor-pannellum-viewer') .classed('hide', true) + .on('mousedown', function(e) { e.stopPropagation(); }) .on('keydown', function(e) { e.stopPropagation(); }); if (!window.pannellum) { @@ -91,6 +92,10 @@ export default { ]); }, + /** + * Shows the photo frame if hidden + * @param {*} context the HTML wrap of the frame + */ showPhotoFrame: function (context) { const isHidden = context.selectAll('.photo-frame.pannellum-frame.hide').size(); @@ -105,8 +110,12 @@ export default { } return this; - }, + }, + /** + * Hides the photo frame if shown + * @param {*} context the HTML wrap of the frame + */ hidePhotoFrame: function (viewerContext) { viewerContext .select('photo-frame.pannellum-frame') @@ -115,6 +124,11 @@ export default { return this; }, + /** + * Renders an image inside the frame + * @param {*} data the image data, it should contain an image_path attribute, a link to the actual image. + * @param {boolean} keepOrientation if true, HFOV, pitch and yaw will be kept between images + */ selectPhoto: function (data, keepOrientation) { const {key} = data; if ( !(key in _currScenes) ) { @@ -135,12 +149,14 @@ export default { let yaw = 0; let pitch = 0; + let hfov = 0; if (keepOrientation) { yaw = this.getYaw(); - pitch = _pannellumViewer.getPitch(); + pitch = this.getPitch(); + hfov = this.getHfov(); } - _pannellumViewer.loadScene(key, pitch, yaw); + _pannellumViewer.loadScene(key, pitch, yaw, hfov); dispatch.call('viewerChanged'); if (_currScenes.length > 3) { @@ -155,6 +171,14 @@ export default { getYaw: function() { return _pannellumViewer.getYaw(); + }, + + getPitch: function() { + return _pannellumViewer.getPitch(); + }, + + getHfov: function() { + return _pannellumViewer.getHfov(); } }; diff --git a/modules/services/panoramax.js b/modules/services/panoramax.js index 14fb10915..4369cb611 100644 --- a/modules/services/panoramax.js +++ b/modules/services/panoramax.js @@ -37,8 +37,6 @@ let _planeFrame; let _pannellumFrame; let _currentFrame; -let _oldestDate; - let _currentScene = { currentImage : null, nextImage : null, @@ -58,8 +56,13 @@ function partitionViewport(projection) { .map(function(tile) { return tile.extent; }); } - -// Return no more than `limit` results per partition. +/** + * Return no more than `limit` results per partition. + * @param {number} limit Number of maximum objects to return + * @param {*} projection Current projection + * @param {*} rtree The cache + * @returns Data found + */ function searchLimited(limit, projection, rtree) { limit = limit || 5; @@ -82,7 +85,14 @@ function searchLimited(limit, projection, rtree) { }, []); } -// Load all data for the specified type from Panoramax vector tiles +/** + * Load all data for the specified type from Panoramax vector tiles + * @param {string} which Either 'images' or 'lines' + * @param {string} url Tile endpoint + * @param {number} maxZoom Maximum zoom out + * @param {*} projection Current projection + * @param {number} zoom current zoom + */ function loadTiles(which, url, maxZoom, projection, zoom) { const tiler = utilTiler().zoomExtent([minZoom, maxZoom]).skipNullIsland(true); const tiles = tiler.getTiles(projection); @@ -92,8 +102,13 @@ function loadTiles(which, url, maxZoom, projection, zoom) { }); } - -// Load all data for the specified type from one vector tile +/** + * Load all data for the specified type from one vector tile + * @param {*} which Either 'images' or 'lines' + * @param {*} url Tile endpoint + * @param {*} tile Current tile + * @param {*} zoom Current zoom + */ function loadTile(which, url, tile, zoom) { const cache = _cache.requests; const tileId = `${tile.id}-${which}`; @@ -135,6 +150,12 @@ function loadTile(which, url, tile, zoom) { }); } +/** + * Fetches all data for the specified tile and adds them to cache + * @param {*} data Tile data + * @param {*} tile Current tile + * @param {*} zoom Current zoom + */ function loadTileDataToCache(data, tile, zoom) { const vectorTile = new VectorTile(new Protobuf(data)); @@ -164,7 +185,6 @@ function loadTileDataToCache(data, tile, zoom) { sequence_id: feature.properties.sequences.split('\"')[1], heading: parseInt(feature.properties.heading, 10), image_path: '', - resolution: feature.properties.resolution, isPano: feature.properties.type === 'equirectangular', model: feature.properties.model, }; @@ -172,14 +192,6 @@ function loadTileDataToCache(data, tile, zoom) { features.push({ minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d }); - - if (_oldestDate){ - if (d.capture_time < _oldestDate){ - _oldestDate = d.capture_time; - } - } else { - _oldestDate = d.capture_time; - } } if (cache.rtree) { cache.rtree.load(features); @@ -201,29 +213,15 @@ function loadTileDataToCache(data, tile, zoom) { } else { cache.lineString[feature.properties.id] = [feature]; } - if (_oldestDate){ - if (feature.properties.date < _oldestDate){ - _oldestDate = feature.properties.date; - } - } else { - _oldestDate = feature.properties.date; - } } } } -async function getImageData(collection_id, image_id){ - const requestUrl = imageDataUrl.replace('{collectionId}', collection_id) - .replace('{itemId}', image_id); - - const response = await fetch(requestUrl, { method: 'GET' }); - if (!response.ok) { - throw new Error(response.status + ' ' + response.statusText); - } - const data = await response.json(); - return data; -} - +/** + * Fetches the username from Panoramax + * @param {string} user_id + * @returns the username + */ async function getUsername(user_id){ const requestUrl = usernameURL.replace('{userId}', user_id); @@ -260,26 +258,46 @@ export default { _activeImage = null; }, - // Get visible images + /** + * Get visible images from cache + * @param {*} projection Current Projection + * @returns images data for the current projection + */ images: function(projection) { const limit = 5; return searchLimited(limit, projection, _cache.images.rtree); }, + /** + * Get a specific image from cache + * @param {*} imageKey the image id + * @returns + */ cachedImage: function(imageKey) { return _cache.images.forImageId[imageKey]; }, - // Load images in the visible area + /** + * Fetches images data for the visible area + * @param {*} projection Current Projection + */ loadImages: function(projection) { loadTiles('images', tileUrl, imageMinZoom, projection); }, - // Load line in the visible area + /** + * Fetches sequences data for the visible area + * @param {*} projection Current Projection + */ loadLines: function(projection, zoom) { loadTiles('line', tileUrl, lineMinZoom, projection, zoom); }, + /** + * Fetches all possible userIDs from Panoramax + * @param {string} usernames one or multiple usernames + * @returns userIDs + */ getUserIds: async function(usernames) { const requestUrls = usernames.map(username => userIdUrl.replace('{username}', username)); @@ -296,11 +314,12 @@ export default { return data.flatMap((d, i) => d.features.filter(f => f.name === usernames[i]).map(f => f.id)); }, - getOldestDate: function(){ - return _oldestDate; - }, - - // Get visible sequences + /** + * Get visible sequences from cache + * @param {*} projection Current Projection + * @param {number} zoom Current zoom (if zoom < `lineMinZoom` less accurate lines will be drawn) + * @returns sequences data for the current projection + */ sequences: function(projection, zoom) { const viewport = projection.clipExtent(); const min = [viewport[0][0], viewport[1][1]]; @@ -330,9 +349,12 @@ export default { return lineStrings; }, - // Set the currently visible image + /** + * Updates the data for the currently visible image + * @param {*} image Image data + */ setActiveImage: function(image) { - if (image) { + if (image && image.id && image.sequence_id) { _activeImage = { id: image.id, sequence_id: image.sequence_id @@ -346,7 +368,11 @@ export default { return _activeImage; }, - // Update the currently highlighted sequence and selected bubble. + /** + * Update the currently highlighted sequence and selected bubble + * @param {*} context Current HTML context + * @param {*} [hovered] The hovered bubble image + */ setStyles: function(context, hovered) { const hoveredImageId = hovered && hovered.id; const hoveredSequenceId = hovered && hovered.sequence_id; @@ -377,10 +403,13 @@ export default { return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'; } } - return this; }, + /** + * Updates the URL to save the current shown image + * @param {*} imageKey + */ updateUrlImage: function(imageKey) { if (!window.mocha) { var hash = utilStringQs(window.location.hash); @@ -393,6 +422,12 @@ export default { } }, + /** + * Loads the selected image in the frame + * @param {*} context Current HTML context + * @param {*} id of the selected image + * @returns + */ selectImage: function (context, id) { let that = this; @@ -472,7 +507,7 @@ export default { .attr('href', viewerLink) .text('panoramax.xyz'); - getImageData(d.sequence_id, d.id).then(function(data){ + this.getImageData(d.sequence_id, d.id).then(function(data){ _currentScene = { currentImage: null, nextImage: null, @@ -526,7 +561,7 @@ export default { line2 .append('span') .attr('class', 'captured_by') - .text(t('panoramax.captured_by', {username})); + .text('@' + username); }); } @@ -537,6 +572,24 @@ export default { return _currentFrame; }, + /** + * Fetches the data for a specific image + * @param {*} collection_id + * @param {*} image_id + * @returns The fetched image data + */ + getImageData: async function(collection_id, image_id){ + const requestUrl = imageDataUrl.replace('{collectionId}', collection_id) + .replace('{itemId}', image_id); + + const response = await fetch(requestUrl, { method: 'GET' }); + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + const data = await response.json(); + return data; + }, + ensureViewerLoaded: function(context) { let that = this; @@ -594,6 +647,11 @@ export default { _planeFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); }); + /** + * Loads the next image in the sequence + * @param {number} stepBy '-1' if backwards or '1' if foward + * @returns + */ function step(stepBy) { return function () { if (!_currentScene.currentImage) return; @@ -616,6 +674,10 @@ export default { return _loadViewerPromise; }, + /** + * Shows the current viewer if hidden + * @param {*} context + */ showViewer: function (context) { let wrap = context.container().select('.photoviewer') .classed('hide', false); @@ -631,6 +693,10 @@ export default { return this; }, + /** + * Hides the current viewer if shown, resets the active image and sequence + * @param {*} context + */ hideViewer: function (context) { let viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(null); @@ -641,7 +707,7 @@ export default { .classed('hide', true); context.container().selectAll('.viewfield-group, .sequence, .icon-sign') .classed('currentView', false); - this.setActiveImage(); + this.setActiveImage(null); return this.setStyles(context, null); }, diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 4261ed085..70baffa62 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -5,25 +5,16 @@ import { utilSetTransform, utilRebind } from '../util'; const dispatch = d3_dispatch('viewerChanged'); let _photo; -let _wrapper; -let imgZoom; -let _widthOverflow; +let _imageWrapper; +let _planeWrapper; +let _imgZoom = d3_zoom() + .extent([[0, 0], [320, 240]]) + .translateExtent([[0, 0], [320, 240]]) + .scaleExtent([1, 15]); 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(); - const {naturalHeight, naturalWidth} = _photo.node(); - const intrinsicRatio = naturalWidth / naturalHeight; - _widthOverflow = wrapperHeight * intrinsicRatio - wrapperWidth; - return d3_zoom() - .extent([[0, 0], [wrapperWidth, wrapperHeight]]) - .translateExtent([[0, 0], [wrapperWidth + _widthOverflow, wrapperHeight]]) - .scaleExtent([1, 15]) - .on('zoom', zoomPan); + _imageWrapper.call(utilSetTransform, t.x, t.y, t.k); } function loadImage (selection, path) { @@ -35,31 +26,38 @@ function loadImage (selection, path) { }); } - export default { init: async function(context, selection) { this.event = utilRebind(this, dispatch, 'on'); - _wrapper = selection + _planeWrapper = selection; + _planeWrapper.call(_imgZoom.on('zoom', zoomPan)); + + _imageWrapper = _planeWrapper .append('div') .attr('class', 'photo-frame plane-frame') .classed('hide', true); - _photo = _wrapper + _photo = _imageWrapper .append('img') .attr('class', 'plane-photo'); - context.ui().photoviewer.on('resize.plane', () => { - imgZoom = zoomBeahvior(); - _wrapper.call(imgZoom); - }); + context.ui().photoviewer.on('resize.plane', function(dimensions) { + _imgZoom + .extent([[0, 0], dimensions]) + .translateExtent([[0, 0], dimensions]); + }); await Promise.resolve(); return this; }, + /** + * Shows the photo frame if hidden + * @param {*} context the HTML wrap of the frame + */ showPhotoFrame: function (context) { const isHidden = context.selectAll('.photo-frame.plane-frame.hide').size(); @@ -76,7 +74,10 @@ export default { return this; }, - + /** + * Hides the photo frame if shown + * @param {*} context the HTML wrap of the frame + */ hidePhotoFrame: function (context) { context .select('photo-frame.plane-frame') @@ -85,15 +86,17 @@ export default { return this; }, + /** + * Renders an image inside the frame + * @param {*} data the image data, it should contain an image_path attribute, a link to the actual image. + */ selectPhoto: function (data) { dispatch.call('viewerChanged'); loadImage(_photo, ''); loadImage(_photo, data.image_path) .then(() => { - imgZoom = zoomBeahvior(); - _wrapper.call(imgZoom); - _wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0)); + _planeWrapper.call(_imgZoom.transform, d3_zoomIdentity); }); return this; }, diff --git a/modules/svg/kartaview_images.js b/modules/svg/kartaview_images.js index 3beb60862..3fc7ef868 100644 --- a/modules/svg/kartaview_images.js +++ b/modules/svg/kartaview_images.js @@ -138,19 +138,19 @@ export function svgKartaviewImages(projection, context, dispatch) { if (fromDate) { var fromTimestamp = new Date(fromDate).getTime(); - sequences = sequences.filter(function(image) { - return new Date(image.properties.captured_at).getTime() >= fromTimestamp; + sequences = sequences.filter(function(sequence) { + return new Date(sequence.properties.captured_at).getTime() >= fromTimestamp; }); } if (toDate) { var toTimestamp = new Date(toDate).getTime(); - sequences = sequences.filter(function(image) { - return new Date(image.properties.captured_at).getTime() <= toTimestamp; + sequences = sequences.filter(function(sequence) { + return new Date(sequence.properties.captured_at).getTime() <= toTimestamp; }); } if (usernames) { - sequences = sequences.filter(function(image) { - return usernames.indexOf(image.properties.captured_by) !== -1; + sequences = sequences.filter(function(sequence) { + return usernames.indexOf(sequence.properties.captured_by) !== -1; }); } @@ -169,12 +169,11 @@ export function svgKartaviewImages(projection, context, dispatch) { var sequences = []; var images = []; - if (context.photos().showsFlat()) { - sequences = (service ? service.sequences(projection) : []); - images = (service && showMarkers ? service.images(projection) : []); - sequences = filterSequences(sequences); - images = filterImages(images); - } + sequences = (service ? service.sequences(projection) : []); + images = (service && showMarkers ? service.images(projection) : []); + dispatch.call('photoDatesChanged', this, 'kartaview', [...images.map(p => p.captured_at), ...sequences.map(s => s.properties.captured_at)]); + sequences = filterSequences(sequences); + images = filterImages(images); var traces = layer.selectAll('.sequences').selectAll('.sequence') .data(sequences, function(d) { return d.properties.key; }); @@ -276,8 +275,11 @@ export function svgKartaviewImages(projection, context, dispatch) { update(); service.loadImages(projection); } else { + dispatch.call('photoDatesChanged', this, 'kartaview', []); editOff(); } + } else { + dispatch.call('photoDatesChanged', this, 'kartaview', []); } } diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 518bc5ce8..b087cbc0b 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -24,7 +24,7 @@ import { utilGetDimensions, utilSetDimensions } from '../util/dimensions'; export function svgLayers(projection, context) { - var dispatch = d3_dispatch('change'); + var dispatch = d3_dispatch('change', 'photoDatesChanged'); var svg = d3_select(null); var _layers = [ { id: 'osm', layer: svgOsm(projection, context, dispatch) }, diff --git a/modules/svg/local_photos.js b/modules/svg/local_photos.js index 414cda283..a1406e191 100644 --- a/modules/svg/local_photos.js +++ b/modules/svg/local_photos.js @@ -2,6 +2,7 @@ import { select as d3_select } from 'd3-selection'; import exifr from 'exifr'; import { isArray, isNumber } from 'lodash-es'; +import { localizer } from '../core/localizer'; import { utilDetect } from '../util/detect'; import { geoExtent, geoPolygonIntersectsPolygon } from '../geo'; import planePhotoFrame from '../services/plane_photo'; @@ -124,7 +125,7 @@ export function svgLocalPhotos(projection, context, dispatch) { if (image.date) { attribution .append('span') - .text(image.date.toLocaleString()); + .text(image.date.toLocaleString(localizer.localeCode())); } if (image.name) { attribution diff --git a/modules/svg/mapilio_images.js b/modules/svg/mapilio_images.js index 70c56775a..f8a7eaaf7 100644 --- a/modules/svg/mapilio_images.js +++ b/modules/svg/mapilio_images.js @@ -102,6 +102,47 @@ export function svgMapilioImages(projection, context, dispatch) { if (service) service.setStyles(context, null); } + + function filterImages(images) { + var fromDate = context.photos().fromDate(); + var toDate = context.photos().toDate(); + + if (fromDate) { + var fromTimestamp = new Date(fromDate).getTime(); + images = images.filter(function(photo) { + return new Date(photo.capture_time).getTime() >= fromTimestamp; + }); + } + if (toDate) { + var toTimestamp = new Date(toDate).getTime(); + images = images.filter(function(photo) { + return new Date(photo.capture_time).getTime() <= toTimestamp; + }); + } + + return images; + } + + function filterSequences(sequences) { + var fromDate = context.photos().fromDate(); + var toDate = context.photos().toDate(); + + if (fromDate) { + var fromTimestamp = new Date(fromDate).getTime(); + sequences = sequences.filter(function(sequence) { + return new Date(sequence.properties.capture_time).getTime() >= fromTimestamp; + }); + } + if (toDate) { + var toTimestamp = new Date(toDate).getTime(); + sequences = sequences.filter(function(sequence) { + return new Date(sequence.properties.capture_time).getTime() <= toTimestamp; + }); + } + + return sequences; + } + function update() { const z = ~~context.map().zoom(); @@ -111,6 +152,11 @@ export function svgMapilioImages(projection, context, dispatch) { let sequences = (service ? service.sequences(projection) : []); let images = (service ? service.images(projection) : []); + dispatch.call('photoDatesChanged', this, 'mapilio', [...images.map(p => p.capture_time), ...sequences.map(s => s.properties.capture_time)]); + + sequences = filterSequences(sequences); + images = filterImages(images); + let traces = layer.selectAll('.sequences').selectAll('.sequence') .data(sequences, function(d) { return d.properties.id; }); @@ -218,8 +264,11 @@ export function svgMapilioImages(projection, context, dispatch) { service.loadImages(projection); service.loadLines(projection); } else { + dispatch.call('photoDatesChanged', this, 'mapilio', []); editOff(); } + } else { + dispatch.call('photoDatesChanged', this, 'mapilio', []); } } diff --git a/modules/svg/mapillary_images.js b/modules/svg/mapillary_images.js index b000b7534..0211c0133 100644 --- a/modules/svg/mapillary_images.js +++ b/modules/svg/mapillary_images.js @@ -180,6 +180,7 @@ export function svgMapillaryImages(projection, context, dispatch) { // "is_pano":false, // "sequence_id":"zcyumxorbza3dq3twjybam" // } + dispatch.call('photoDatesChanged', this, 'mapillary', [...images.map(p => p.captured_at), ...sequences.map(s => s.properties.captured_at)]); images = filterImages(images); sequences = filterSequences(sequences, service); @@ -293,8 +294,11 @@ export function svgMapillaryImages(projection, context, dispatch) { update(); service.loadImages(projection); } else { + dispatch.call('photoDatesChanged', this, 'mapillary', []); editOff(); } + } else { + dispatch.call('photoDatesChanged', this, 'mapillary', []); } } diff --git a/modules/svg/panoramax_images.js b/modules/svg/panoramax_images.js index e4857ece3..32c242085 100644 --- a/modules/svg/panoramax_images.js +++ b/modules/svg/panoramax_images.js @@ -13,7 +13,6 @@ export function svgPanoramaxImages(projection, context, dispatch) { let layer = d3_select(null); let _panoramax; let _viewerYaw = 0; - let _selectedSequence; let _activeUsernameFilter; let _activeIds; @@ -23,7 +22,6 @@ export function svgPanoramaxImages(projection, context, dispatch) { svgPanoramaxImages.initialized = true; } - function getService() { if (services.panoramax && !_panoramax) { _panoramax = services.panoramax; @@ -38,6 +36,11 @@ export function svgPanoramaxImages(projection, context, dispatch) { return _panoramax; } + /** + * Filters the images given the filters on the right panel + * @param {*} images + * @returns array of filtered images + */ async function filterImages(images) { const showsPano = context.photos().showsPanoramic(); const showsFlat = context.photos().showsFlat(); @@ -83,6 +86,11 @@ export function svgPanoramaxImages(projection, context, dispatch) { return images; } + /** + * Filters the sequences given the filters on the right panel + * @param {*} sequences + * @returns array of filtered sequences + */ async function filterSequences(sequences) { const showsPano = context.photos().showsPanoramic(); const showsFlat = context.photos().showsFlat(); @@ -128,6 +136,9 @@ export function svgPanoramaxImages(projection, context, dispatch) { return sequences; } + /** + * Shows the selected layer + */ function showLayer() { const service = getService(); if (!service) return; @@ -142,7 +153,9 @@ export function svgPanoramaxImages(projection, context, dispatch) { .on('end', function () { dispatch.call('change'); }); } - + /** + * Hides the selected layer + */ function hideLayer() { throttledRedraw.cancel(); @@ -153,6 +166,11 @@ export function svgPanoramaxImages(projection, context, dispatch) { .on('end', editOff); } + /** + * Updates the viewfinder for the selected image bubble based on the frame's yaw + * @param {*} d Current Active image Data + * @param {*} selectedImageId The selected bubble image ID + */ function transform(d, selectedImageId) { let t = svgPointTransform(projection)(d); let rot = d.heading; @@ -165,27 +183,25 @@ export function svgPanoramaxImages(projection, context, dispatch) { return t; } - function editOn() { layer.style('display', 'block'); } - function editOff() { + const service = getService(); + service.hideViewer(context); layer.selectAll('.viewfield-group').remove(); layer.style('display', 'none'); } + /** + * Updates the current selected image + * @param {*} image The selected image bubble data + */ function click(d3_event, image) { const service = getService(); if (!service) return; - if (image.sequence_id !== _selectedSequence) { - _viewerYaw = 0; // reset - } - - _selectedSequence = image.sequence_id; - service .ensureViewerLoaded(context) .then(function() { @@ -202,12 +218,14 @@ export function svgPanoramaxImages(projection, context, dispatch) { if (service) service.setStyles(context, image); } - function mouseout() { const service = getService(); if (service) service.setStyles(context, null); } + /** + * Updates the current view, rearranging lines and bubbles. + */ async function update() { const zoom = ~~context.map().zoom(); const showViewfields = (zoom >= viewFieldZoomLevel); @@ -215,6 +233,11 @@ export function svgPanoramaxImages(projection, context, dispatch) { const service = getService(); let sequences = (service ? service.sequences(projection, zoom) : []); let images = (service && zoom >= imageMinZoom ? service.images(projection) : []); + dispatch.call('photoDatesChanged', this, 'panoramax', [...images.map(p => p.capture_time), ...sequences.map(s => s.properties.date)]); + + let isHidden = d3_select('.photo-wrapper.panoramax-wrapper.hide').size(); + + if (isHidden) service.setActiveImage(null); images = await filterImages(images); sequences = await filterSequences(sequences, service); @@ -317,6 +340,10 @@ export function svgPanoramaxImages(projection, context, dispatch) { } + /** + * Draws bubbles and lines on the current view + * @param {*} selection Current HTML Selection + */ function drawImages(selection) { const enabled = svgPanoramaxImages.enabled; @@ -357,13 +384,19 @@ export function svgPanoramaxImages(projection, context, dispatch) { service.loadLines(projection, zoom); } else { editOff(); + dispatch.call('photoDatesChanged', this, 'panoramax', []); } } else { editOff(); } + } else { + dispatch.call('photoDatesChanged', this, 'panoramax', []); } } + /** + * @returns if layer is active + */ drawImages.enabled = function(_) { if (!arguments.length) return svgPanoramaxImages.enabled; svgPanoramaxImages.enabled = _; @@ -383,6 +416,9 @@ export function svgPanoramaxImages(projection, context, dispatch) { return !!getService(); }; + /** + * @returns if layer is drawn + */ drawImages.rendered = function(zoom) { return zoom >= lineMinZoom; }; diff --git a/modules/svg/streetside.js b/modules/svg/streetside.js index 73ec4eeb7..270808087 100644 --- a/modules/svg/streetside.js +++ b/modules/svg/streetside.js @@ -233,6 +233,8 @@ export function svgStreetside(projection, context, dispatch) { var traces = layer.selectAll('.sequences').selectAll('.sequence') .data(sequences, function(d) { return d.properties.key; }); + dispatch.call('photoDatesChanged', this, 'streetside', [...bubbles.map(p => p.captured_at), ...sequences.map(t => t.properties.vintageStart)]); + // exit traces.exit() .remove(); @@ -349,8 +351,11 @@ export function svgStreetside(projection, context, dispatch) { update(); service.loadBubbles(projection); } else { + dispatch.call('photoDatesChanged', this, 'streetside', []); editOff(); } + } else { + dispatch.call('photoDatesChanged', this, 'streetside', []); } } diff --git a/modules/svg/vegbilder.js b/modules/svg/vegbilder.js index 5563e5ebc..cada3c9e8 100644 --- a/modules/svg/vegbilder.js +++ b/modules/svg/vegbilder.js @@ -222,6 +222,9 @@ export function svgVegbilder(projection, context, dispatch) { sequences = service.sequences(projection); images = showMarkers ? service.images(projection) : []; + + dispatch.call('photoDatesChanged', this, 'vegbilder', images.map(p => p.captured_at)); + images = filterImages(images); sequences = filterSequences(sequences); } diff --git a/modules/ui/sections/photo_overlays.js b/modules/ui/sections/photo_overlays.js index ba8c2d1cd..1f0aa9d29 100644 --- a/modules/ui/sections/photo_overlays.js +++ b/modules/ui/sections/photo_overlays.js @@ -4,12 +4,16 @@ import { select as d3_select } from 'd3-selection'; import { localizer, t } from '../../core/localizer'; import { uiTooltip } from '../tooltip'; import { uiSection } from '../section'; -import { utilGetSetValue, utilNoAuto } from '../../util'; +import { utilNoAuto } from '../../util'; import { uiSettingsLocalPhotos } from '../settings/local_photos'; import { svgIcon } from '../../svg'; export function uiSectionPhotoOverlays(context) { + let _savedLayers = []; + let _layersHidden = false; + const _streetLayerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio', 'vegbilder', 'panoramax']; + var settingsLocalPhotos = uiSettingsLocalPhotos(context) .on('change', localPhotosChanged); @@ -20,6 +24,13 @@ export function uiSectionPhotoOverlays(context) { .disclosureContent(renderDisclosureContent) .expandedByDefault(false); + const photoDates = {}; + const now = +new Date(); + + /** + * Calls all draw function + * @param {*} selection Current HTML selection + */ function renderDisclosureContent(selection) { var container = selection.selectAll('.photo-overlay-container') .data([0]); @@ -30,11 +41,14 @@ export function uiSectionPhotoOverlays(context) { .merge(container) .call(drawPhotoItems) .call(drawPhotoTypeItems) - .call(drawDateFilter) + .call(drawDateSlider) .call(drawUsernameFilter) .call(drawLocalPhotos); } + /** + * Draws the streetlevels in the right panel + */ function drawPhotoItems(selection) { var photoKeys = context.photos().overlayLayerIDs(); var photoLayers = layers.all().filter(function(obj) { return photoKeys.indexOf(obj.id) !== -1; }); @@ -51,7 +65,7 @@ export function uiSectionPhotoOverlays(context) { return d.layer && d.layer.supported(); } function layerEnabled(d) { - return layerSupported(d) && d.layer.enabled(); + return layerSupported(d) && (d.layer.enabled() || _savedLayers.includes(d.id)); } function layerRendered(d) { return d.layer.rendered?.(context.map().zoom()) ?? true; @@ -125,6 +139,9 @@ export function uiSectionPhotoOverlays(context) { .property('checked', layerEnabled); } + /** + * Draws the photo type filter in the right panel + */ function drawPhotoTypeItems(selection) { var data = context.photos().allPhotoTypes(); @@ -170,7 +187,7 @@ export function uiSectionPhotoOverlays(context) { .append('input') .attr('type', 'checkbox') .on('change', function(d3_event, d) { - context.photos().togglePhotoType(d); + context.photos().togglePhotoType(d, true); }); labelEnter @@ -188,15 +205,13 @@ export function uiSectionPhotoOverlays(context) { .property('checked', typeEnabled); } - function drawDateFilter(selection) { - var data = context.photos().dateFilters(); - - function filterEnabled(d) { - return context.photos().dateFilterValue(d); - } + /** + * Draws the date slider filter in the right panel + */ + function drawDateSlider(selection){ var ul = selection - .selectAll('.layer-list-date-filter') + .selectAll('.layer-list-date-slider') .data([0]); ul.exit() @@ -204,59 +219,153 @@ export function uiSectionPhotoOverlays(context) { ul = ul.enter() .append('ul') - .attr('class', 'layer-list layer-list-date-filter') + .attr('class', 'layer-list layer-list-date-slider') .merge(ul); - var li = ul.selectAll('.list-item-date-filter') - .data(context.photos().shouldFilterByDate() ? data : []); + var li = ul.selectAll('.list-item-date-slider') + .data(context.photos().shouldFilterDateBySlider() ? ['date-slider'] : []); li.exit() .remove(); var liEnter = li.enter() .append('li') - .attr('class', 'list-item-date-filter'); + .attr('class', 'list-item-date-slider'); var labelEnter = liEnter - .append('label') - .each(function(d) { - d3_select(this) - .call(uiTooltip() - .title(() => t.append('photo_overlays.date_filter.' + d + '.tooltip')) - .placement('top') - ); - }); + .append('label') + .each(function() { + d3_select(this) + .call(uiTooltip() + .title(() => t.append('photo_overlays.age_slider_filter.tooltip')) + .placement('top') + ); + }); labelEnter .append('span') - .each(function(d) { - t.append('photo_overlays.date_filter.' + d + '.title')(d3_select(this)); - }); + .attr('class', 'dateSliderSpan') + .call(t.append('photo_overlays.age_slider_filter.title')); - labelEnter + let sliderWrap = labelEnter + .append('div') + .attr('class','slider-wrap'); + + sliderWrap .append('input') - .attr('type', 'date') - .attr('class', 'list-item-input') - .attr('placeholder', t('units.year_month_day')) + .attr('type', 'range') + .attr('min', 0) + .attr('max', 1) + .attr('step', 0.001) + .attr('list', 'photo-overlay-data-range') + .attr('value', () => dateSliderValue('from')) + .classed('list-option-date-slider', true) + .classed('from-date', true) + .style('direction', localizer.textDirection() === 'rtl' ? 'ltr' : 'rtl') .call(utilNoAuto) - .each(function(d) { - utilGetSetValue(d3_select(this), context.photos().dateFilterValue(d) || ''); - }) - .on('change', function(d3_event, d) { - var value = utilGetSetValue(d3_select(this)).trim(); - context.photos().setDateFilter(d, value, true); - // reload the displayed dates - li.selectAll('input') - .each(function(d) { - utilGetSetValue(d3_select(this), context.photos().dateFilterValue(d) || ''); - }); + .on('change', function() { + let value = d3_select(this).property('value'); + setYearFilter(value, true, 'from'); }); + selection.select('input.from-date').each(function() { this.value = dateSliderValue('from'); }); - li = li + sliderWrap.append('div') + .attr('class', 'date-slider-label'); + + sliderWrap + .append('input') + .attr('type', 'range') + .attr('min', 0) + .attr('max', 1) + .attr('step', 0.001) + .attr('list', 'photo-overlay-data-range-inverted') + .attr('value', () => 1 - dateSliderValue('to')) + .classed('list-option-date-slider', true) + .classed('to-date', true) + .style('display', () => dateSliderValue('to') === 0 ? 'none' : null) + .style('direction', localizer.textDirection()) + .call(utilNoAuto) + .on('change', function() { + let value = d3_select(this).property('value'); + setYearFilter(1-value, true, 'to'); + }); + selection.select('input.to-date').each(function() { this.value = 1 - dateSliderValue('to'); }); + + selection.select('.date-slider-label') + .call(dateSliderValue('from') === 1 + ? t.addOrUpdate('photo_overlays.age_slider_filter.label_all') + : t.addOrUpdate('photo_overlays.age_slider_filter.label_date', { + date: new Date(now - Math.pow(dateSliderValue('from'), 1.45) * 10 * 365.25 * 86400 * 1000).toLocaleDateString(localizer.localeCode()) })); + + sliderWrap.append('datalist') + .attr('class', 'date-slider-values') + .attr('id', 'photo-overlay-data-range'); + sliderWrap.append('datalist') + .attr('class', 'date-slider-values') + .attr('id', 'photo-overlay-data-range-inverted'); + + const dateTicks = new Set(); + for (const dates of Object.values(photoDates)) { + dates.forEach(date => { + dateTicks.add(Math.round(1000 * Math.pow((now - date) / (10 * 365.25 * 86400 * 1000), 1/1.45)) / 1000); + }); + } + const ticks = selection.select('datalist#photo-overlay-data-range').selectAll('option') + .data([...dateTicks].concat([1, 0])); + ticks.exit() + .remove(); + ticks.enter() + .append('option') + .merge(ticks) + .attr('value', d => d); + const ticksInverted = selection.select('datalist#photo-overlay-data-range-inverted').selectAll('option') + .data([...dateTicks].concat([1, 0])); + ticksInverted.exit() + .remove(); + ticksInverted.enter() + .append('option') + .merge(ticksInverted) + .attr('value', d => 1 - d); + + + li .merge(liEnter) .classed('active', filterEnabled); + + function filterEnabled() { + return !!context.photos().fromDate(); + } } + function dateSliderValue(which) { + const val = which === 'from' ? context.photos().fromDate() : context.photos().toDate(); + if (val) { + const date = +new Date(val); + return Math.pow((now - date) / (10 * 365.25 * 86400 * 1000), 1/1.45); + } else return which === 'from' ? 1 : 0; + } + + /** + * Util function to set the slider date filter + * @param {Number} value The slider value + * @param {Boolean} updateUrl whether the URL should update or not + * @param {string} which to set either the 'from' or 'to' date + */ + function setYearFilter(value, updateUrl, which){ + value = +value + (which === 'from' ? 0.001 : -0.001); + + if (value < 1 && value > 0) { + const date = new Date(now - Math.pow(value, 1.45) * 10 * 365.25 * 86400 * 1000) + .toISOString().substring(0,10); + context.photos().setDateFilter(`${which}Date`, date, updateUrl); + } else { + context.photos().setDateFilter(`${which}Date`, null, updateUrl); + } + }; + + /** + * Draws the username filter in the right panel + */ function drawUsernameFilter(selection) { function filterEnabled() { return context.photos().usernames(); @@ -320,10 +429,18 @@ export function uiSectionPhotoOverlays(context) { } } + /** + * Toggle on/off the selected layer + * @param {*} which Id of the selected layer + */ function toggleLayer(which) { setLayer(which, !showsLayer(which)); } + /** + * @param {*} which Id of the selected layer + * @returns whether the layer is enabled + */ function showsLayer(which) { var layer = layers.layer(which); if (layer) { @@ -332,6 +449,11 @@ export function uiSectionPhotoOverlays(context) { return false; } + /** + * Set the selected layer + * @param {string} which Id of the selected layer + * @param {boolean} enabled + */ function setLayer(which, enabled) { var layer = layers.layer(which); if (layer) { @@ -429,8 +551,36 @@ export function uiSectionPhotoOverlays(context) { localPhotosLayer.fileList(d); } + /** + * Toggles StreetView on/off + */ + function toggleStreetSide(){ + let layerContainer = d3_select('.photo-overlay-container'); + if (!_layersHidden){ + layers.all().forEach(d => { + if (_streetLayerIDs.includes(d.id)) { + if (showsLayer(d.id)) _savedLayers.push(d.id); + setLayer(d.id, false); + } + }); + layerContainer.classed('disabled-panel', true); + } else { + _savedLayers.forEach(d => { + setLayer(d, true); + }); + _savedLayers = []; + layerContainer.classed('disabled-panel', false); + } + _layersHidden = !_layersHidden; + }; + context.layers().on('change.uiSectionPhotoOverlays', section.reRender); context.photos().on('change.uiSectionPhotoOverlays', section.reRender); + context.layers().on('photoDatesChanged.uiSectionPhotoOverlays', function(service, dates) { + photoDates[service] = dates.map(date => +new Date(date)); + section.reRender(); + }); + context.keybinding().on('⇧P', toggleStreetSide); context.map() .on('move.photo_overlays', diff --git a/test/spec/services/panoramax.js b/test/spec/services/panoramax.js index c69575451..2209291ab 100644 --- a/test/spec/services/panoramax.js +++ b/test/spec/services/panoramax.js @@ -1,6 +1,41 @@ +import { setTimeout } from 'node:timers/promises'; + describe('iD.servicePanoramax', function() { - var dimensions = [64, 64]; + const dimensions = [64, 64]; var context, panoramax; + const data = { + images:[{ + loc: [10,0], + capture_time: '2020-01-01', + id: 'abc', + account_id: '123', + sequence_id: 'a1b2', + heading: 0, + image_path: '', + isPano: true, + model: 'camera', + }, { + loc: [10,1], + capture_time: '2020-02-01', + id: 'def', + account_id: 'c3d4', + sequence_id: '', + heading: 0, + image_path: '', + isPano: true, + model: 'camera', + }, { + loc: [10,2], + capture_time: '2020-02-01', + id: 'ghi', + account_id: '789', + sequence_id: 'e5f6', + heading: 0, + image_path: '', + isPano: true, + model: 'camera', + }], + }; before(function() { iD.services.panoramax = iD.servicePanoramax; @@ -54,6 +89,36 @@ describe('iD.servicePanoramax', function() { }); }); + describe('#loadImages', function() { + it('does not load images around null island', async () => { + var spy = sinon.spy(); + fetchMock.reset(); + fetchMock.mock(new RegExp('/api\.panoramax\.xyz/'), { + body: JSON.stringify(data), + status: 200, + headers: { 'Content-Type': 'application/json' } + }); + + context.projection + .scale(iD.geoZoomToScale(15)) + .translate([0, 0]); + + panoramax.on('loadedImages', spy); + panoramax.loadImages(context.projection); + + await setTimeout(200); + expect(spy).to.have.been.not.called; + expect(fetchMock.calls().length).to.eql(0); // no tile requests of any kind + }); + + it('handle API error response', async ({ expect }) => { + fetchMock.reset(); + fetchMock.mock('/api\.panoramax\.xyz/', 500); + const promise = panoramax.getImageData('collection1', 'image1'); + await expect(promise).rejects.toThrowError(); + }); + }); + describe('#images', function() { it('returns images in the visible map area', function() { var features = [ @@ -85,6 +150,24 @@ describe('iD.servicePanoramax', function() { var res = panoramax.images(context.projection); expect(res).to.have.length.of.at.most(5); }); + + it('handle invalid image data', function() { + const invalidImage = { id: null, sequence_id: null }; + panoramax.setActiveImage(invalidImage); + expect(panoramax.getActiveImage()).to.be.null; + }); + + it('return empty array when no images are available', function() { + const result = panoramax.images(context.projection); + expect(result).to.deep.equal([]); + }); + + it('load images quickly under normal conditions', function() { + const start = performance.now(); + panoramax.loadImages(context.projection); + const duration = performance.now() - start; + expect(duration).to.be.lessThan(1000); + }); }); @@ -114,5 +197,4 @@ describe('iD.servicePanoramax', function() { expect(panoramax.getActiveImage()).to.eql(d); }); }); - });