diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c310052f..2b51f0105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ _Breaking developer changes, which may affect downstream projects or sites that * Sort preset-specific optional fields before universal fields in "Add field" dropdown ([#10181], thanks [@zbycz]) #### :scissors: Operations #### :camera: Street-Level +* Add Panoramax as new street level imagery provider ([#9941], thanks [@mattiapezzotti]) #### :white_check_mark: Validation * Drop deprecated validation service _ImproveOSM_ ([#10302], thanks [@arch0345]) #### :bug: Bugfixes diff --git a/css/60_photos.css b/css/60_photos.css index 2794c48d6..abcbef7be 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -104,6 +104,7 @@ .photo-attribution span { padding: 4px 2px; color: #fff; + text-wrap: nowrap; } /* markers and sequences */ @@ -175,7 +176,7 @@ .sequence { fill: none; stroke-width: 2; - stroke-opacity: 0.4; + stroke-opacity: 0.6; } .sequence.highlighted, .sequence.currentView { @@ -310,7 +311,57 @@ width: 100%; height: 100%; object-fit: cover; - overflow: hidden + overflow: hidden; +} + +/* panoramax Image Layer */ +.layer-panoramax { + pointer-events: none; +} +.layer-panoramax .viewfield-group * { + fill: #1234ae; + stroke: #ffffff; + stroke-opacity: .6; + fill-opacity: .6; +} +.layer-panoramax .sequence { + stroke: #1234ae; +} +.photo-controls-panoramax { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} +.photo-controls-panoramax button { + padding:0 6px; + pointer-events: initial; +} + +label.panoramax-hd { + float: left; + cursor: pointer; +} +.panoramax-hd span { + margin-top: 2px; +} +.panoramax-hd input[type="checkbox"] { + width: 12px; + height: 12px; + margin: 0 5px; +} + +.slider-wrap { + display: inline-block; +} + +.year-datalist { + display: flex; + justify-content: space-between; +} + +.list-option-date-slider{ + direction: rtl } @@ -343,7 +394,8 @@ } .ms-wrapper .pnlm-compass.pnlm-control, -.vegbilder-wrapper .pnlm-compass.pnlm-control { +.vegbilder-wrapper .pnlm-compass.pnlm-control, +.panoramax-wrapper .pnlm-compass.pnlm-control { width: 26px; height: 26px; left: 4px; @@ -435,19 +487,18 @@ label.streetside-hires { .photo-wrapper { position: relative; background-color: #000; -} - -.photoviewer .plane-frame { - display: block; - overflow: hidden; - height: 100%; - width: 100%; background-image: url(img/loader-black.gif); background-position: center; background-repeat: no-repeat; } -.photoviewer .plane-frame > img.plane-photo{ +.photoviewer .plane-frame { + height: 100%; + width: 100%; + transform-origin: 0 0; +} + +.photoviewer .plane-frame > img.plane-photo { width: auto; height: 100%; transform-origin: 0 0; @@ -592,3 +643,4 @@ label.streetside-hires { border-radius: 4px; cursor: pointer; } + diff --git a/data/core.yaml b/data/core.yaml index e04529f71..5cdb0658e 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1424,6 +1424,12 @@ en: mapilio: title: Mapilio tooltip: "Street-level photos from Mapilio" + panoramax: + title: Panoramax + tooltip: "Street-level photos from Panoramax" + report: "Report" + captured_by: "Captured by {username}" + hd: "High resolution" street_side: minzoom_tooltip: "Zoom in to see street-side photos" local_photos: diff --git a/modules/renderer/background.js b/modules/renderer/background.js index 90d69d97f..48eddf956 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -253,7 +253,8 @@ export function rendererBackground(context) { 'mapillary-signs': 'Mapillary Signs', kartaview: 'KartaView Images', vegbilder: 'Norwegian Road Administration Images', - mapilio: 'Mapilio Images' + mapilio: 'Mapilio Images', + panoramax: 'Panoramax Images' }; for (let layerID in photoOverlayLayers) { diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index 7c3fa952a..b28fd9b2f 100644 --- a/modules/renderer/photos.js +++ b/modules/renderer/photos.js @@ -7,7 +7,7 @@ import { utilQsString, utilStringQs } from '../util'; export function rendererPhotos(context) { var dispatch = d3_dispatch('change'); - var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio', 'vegbilder']; + var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio', 'vegbilder', 'panoramax']; var _allPhotoTypes = ['flat', 'panoramic']; var _shownPhotoTypes = _allPhotoTypes.slice(); // shallow copy var _dateFilters = ['fromDate', 'toDate']; @@ -119,16 +119,16 @@ export function rendererPhotos(context) { } photos.shouldFilterByDate = function() { - return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside') || showsLayer('vegbilder'); + return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside') || showsLayer('vegbilder') || showsLayer('panoramax'); }; photos.shouldFilterByPhotoType = function() { return showsLayer('mapillary') || - (showsLayer('streetside') && showsLayer('kartaview')) || showsLayer('vegbilder'); + (showsLayer('streetside') && showsLayer('kartaview')) || showsLayer('vegbilder') || showsLayer('panoramax'); }; photos.shouldFilterByUsername = function() { - return !showsLayer('mapillary') && showsLayer('kartaview') && !showsLayer('streetside'); + return !showsLayer('mapillary') && showsLayer('kartaview') && !showsLayer('streetside') || showsLayer('panoramax'); }; photos.showsPhotoType = function(val) { diff --git a/modules/services/index.js b/modules/services/index.js index 51280dc2d..af98a7c3c 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -14,6 +14,7 @@ import serviceVectorTile from './vector_tile'; import serviceWikidata from './wikidata'; import serviceWikipedia from './wikipedia'; import serviceMapilio from './mapilio'; +import servicePanoramax from './panoramax'; export let services = { @@ -32,7 +33,8 @@ export let services = { vectorTile: serviceVectorTile, wikidata: serviceWikidata, wikipedia: serviceWikipedia, - mapilio: serviceMapilio + mapilio: serviceMapilio, + panoramax: servicePanoramax }; export { @@ -51,5 +53,6 @@ export { serviceVectorTile, serviceWikidata, serviceWikipedia, - serviceMapilio + serviceMapilio, + servicePanoramax }; diff --git a/modules/services/pannellum_photo.js b/modules/services/pannellum_photo.js index 51ac406a8..bebc4de2c 100644 --- a/modules/services/pannellum_photo.js +++ b/modules/services/pannellum_photo.js @@ -119,7 +119,7 @@ export default { let newSceneOptions = { showFullscreenCtrl: false, autoLoad: false, - compass: true, + compass: false, yaw: 0, type: 'equirectangular', preview: data.preview_path, diff --git a/modules/services/panoramax.js b/modules/services/panoramax.js new file mode 100644 index 000000000..20240551e --- /dev/null +++ b/modules/services/panoramax.js @@ -0,0 +1,636 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; + +import Protobuf from 'pbf'; +import RBush from 'rbush'; +import { VectorTile } from '@mapbox/vector-tile'; + +import { utilRebind, utilTiler, utilQsString, utilStringQs, utilUniqueDomId} from '../util'; +import { geoExtent, geoScaleToZoom } from '../geo'; +import { t, localizer } from '../core/localizer'; +import pannellumPhotoFrame from './pannellum_photo'; +import planePhotoFrame from './plane_photo'; + +const apiUrl = 'https://api.panoramax.xyz/'; +const tileUrl = apiUrl + 'api/map/{z}/{x}/{y}.mvt'; +const imageDataUrl = apiUrl + 'api/collections/{collectionId}/items/{itemId}'; +const userIdUrl = apiUrl + 'api/users/search?q={username}'; +const usernameURL = apiUrl + 'api/users/{userId}'; +const viewerUrl = apiUrl; + +const highDefinition = 'hd'; +const standardDefinition = 'sd'; + +const pictureLayer = 'pictures'; +const sequenceLayer = 'sequences'; + +const minZoom = 10; +const imageMinZoom = 15; +const lineMinZoom = 10; +const dispatch = d3_dispatch('loadedImages', 'loadedLines', 'viewerChanged'); + +let _cache; +let _loadViewerPromise; +let _definition = standardDefinition; +let _isHD = false; + +let _planeFrame; +let _pannellumFrame; +let _currentFrame; + +let _oldestDate; + +let _currentScene = { + currentImage : null, + nextImage : null, + prevImage : null +}; + +let _activeImage; + + +// Partition viewport into higher zoom tiles +function partitionViewport(projection) { + const z = geoScaleToZoom(projection.scale()); + const z2 = (Math.ceil(z * 2) / 2) + 2.5; // round to next 0.5 and add 2.5 + const tiler = utilTiler().zoomExtent([z2, z2]); + + return tiler.getTiles(projection) + .map(function(tile) { return tile.extent; }); +} + + +// Return no more than `limit` results per partition. +function searchLimited(limit, projection, rtree) { + limit = limit || 5; + + return partitionViewport(projection) + .reduce(function(result, extent) { + const found = rtree.search(extent.bbox()) + .slice(0, limit) + .map(function(d) { return d.data; }); + + return (found.length ? result.concat(found) : result); + }, []); +} + +// Load all data for the specified type from Panoramax vector tiles +function loadTiles(which, url, maxZoom, projection) { + const tiler = utilTiler().zoomExtent([minZoom, maxZoom]).skipNullIsland(true); + const tiles = tiler.getTiles(projection); + + tiles.forEach(function(tile) { + loadTile(which, url, tile); + }); +} + + +// Load all data for the specified type from one vector tile +function loadTile(which, url, tile) { + const cache = _cache.requests; + const tileId = `${tile.id}-${which}`; + if (cache.loaded[tileId] || cache.inflight[tileId]) return; + const controller = new AbortController(); + cache.inflight[tileId] = controller; + const requestUrl = url.replace('{x}', tile.xyz[0]) + .replace('{y}', tile.xyz[1]) + .replace('{z}', tile.xyz[2]); + + fetch(requestUrl, { signal: controller.signal }) + .then(function(response) { + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + cache.loaded[tileId] = true; + delete cache.inflight[tileId]; + return response.arrayBuffer(); + }) + .then(function(data) { + if (data.byteLength === 0) { + throw new Error('No Data'); + } + + loadTileDataToCache(data, tile, which); + + if (which === 'images') { + dispatch.call('loadedImages'); + } else { + dispatch.call('loadedLines'); + } + }) + .catch(function (e) { + if (e.message === 'No Data') { + cache.loaded[tileId] = true; + } else { + console.error(e); // eslint-disable-line no-console + } + }); +} + +function loadTileDataToCache(data, tile) { + const vectorTile = new VectorTile(new Protobuf(data)); + + let features, + cache, + layer, + i, + feature, + loc, + d; + + if (vectorTile.layers.hasOwnProperty(pictureLayer)) { + features = []; + cache = _cache.images; + layer = vectorTile.layers[pictureLayer]; + + for (i = 0; i < layer.length; i++) { + feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]); + loc = feature.geometry.coordinates; + + d = { + loc: loc, + capture_time: feature.properties.ts, + id: feature.properties.id, + account_id: feature.properties.account_id, + 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, + }; + cache.forImageId[d.id] = d; + 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); + } + } + + if (vectorTile.layers.hasOwnProperty(sequenceLayer)) { + cache = _cache.sequences; + layer = vectorTile.layers[sequenceLayer]; + + for (i = 0; i < layer.length; i++) { + feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]); + if (cache.lineString[feature.properties.id]) { + cache.lineString[feature.properties.id].push(feature); + } 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; +} + +async function getUsername(user_id){ + const requestUrl = usernameURL.replace('{userId}', user_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.name; +} + +export default { + init: function() { + if (!_cache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function() { + if (_cache) { + Object.values(_cache.requests.inflight).forEach(function(request) { request.abort(); }); + } + + _cache = { + images: { rtree: new RBush(), forImageId: {} }, + sequences: { rtree: new RBush(), lineString: {} }, + requests: { loaded: {}, inflight: {} } + }; + + _currentScene.currentImage = null; + _activeImage = null; + }, + + // Get visible images + images: function(projection) { + const limit = 5; + return searchLimited(limit, projection, _cache.images.rtree); + }, + + cachedImage: function(imageKey) { + return _cache.images.forImageId[imageKey]; + }, + + // Load images in the visible area + loadImages: function(projection) { + loadTiles('images', tileUrl, imageMinZoom, projection); + }, + + // Load line in the visible area + loadLines: function(projection) { + loadTiles('line', tileUrl, lineMinZoom, projection); + }, + + getUserIds: async function(usernames) { + const requestUrls = usernames.map(username => + userIdUrl.replace('{username}', username)); + + const responses = await Promise.all(requestUrls.map(requestUrl => + fetch(requestUrl, { method: 'GET' }))); + if (responses.some(response => !response.ok)) { + const response = responses.find(response => !response.ok); + throw new Error(response.status + ' ' + response.statusText); + } + const data = await Promise.all(responses.map(response => response.json())); + // in panoramax, a username can have multiple ids, when the same name is + // used on different servers + return data.flatMap((d, i) => d.features.filter(f => f.name === usernames[i]).map(f => f.id)); + }, + + getOldestDate: function(){ + return _oldestDate; + }, + + // Get visible sequences + sequences: function(projection, zoom) { + const viewport = projection.clipExtent(); + const min = [viewport[0][0], viewport[1][1]]; + const max = [viewport[1][0], viewport[0][1]]; + const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); + const sequenceIds = {}; + let lineStrings = []; + + if (zoom >= imageMinZoom){ + _cache.images.rtree.search(bbox).forEach(function(d) { + if (d.data.sequence_id) { + sequenceIds[d.data.sequence_id] = true; + } + }); + Object.keys(sequenceIds).forEach(function(sequenceId) { + if (_cache.sequences.lineString[sequenceId]) { + lineStrings = lineStrings.concat(_cache.sequences.lineString[sequenceId]); + } + }); + return lineStrings; + } + if (zoom >= lineMinZoom){ + Object.keys(_cache.sequences.lineString).forEach(function(sequenceId) { + lineStrings = lineStrings.concat(_cache.sequences.lineString[sequenceId]); + }); + } + return lineStrings; + }, + + // Set the currently visible image + setActiveImage: function(image) { + if (image) { + _activeImage = { + id: image.id, + sequence_id: image.sequence_id + }; + } else { + _activeImage = null; + } + }, + + getActiveImage: function(){ + return _activeImage; + }, + + // Update the currently highlighted sequence and selected bubble. + setStyles: function(context, hovered) { + const hoveredImageId = hovered && hovered.id; + const hoveredSequenceId = hovered && hovered.sequence_id; + const selectedSequenceId = _activeImage && _activeImage.sequence_id; + const selectedImageId = _activeImage && _activeImage.id; + + const markers = context.container().selectAll('.layer-panoramax .viewfield-group'); + const sequences = context.container().selectAll('.layer-panoramax .sequence'); + + markers + .classed('highlighted', function(d) { return d.id === hoveredImageId; }) + .classed('hovered', function(d) { return d.id === hoveredImageId; }) + .classed('currentView', function(d) { return d.id === selectedImageId; }); + + sequences + .classed('highlighted', function(d) { return d.sequence_id === hoveredSequenceId; }) + .classed('currentView', function(d) { return d.sequence_id === selectedSequenceId; }); + + // update viewfields if needed + context.container().selectAll('.layer-panoramax .viewfield-group .viewfield') + .attr('d', viewfieldPath); + + function viewfieldPath() { + let d = this.parentNode.__data__; + if (d.isPano && d.id !== selectedImageId) { + return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0'; + } else { + 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; + }, + + updateUrlImage: function(imageKey) { + if (!window.mocha) { + var hash = utilStringQs(window.location.hash); + if (imageKey) { + hash.photo = 'panoramax/' + imageKey; + } else { + delete hash.photo; + } + window.location.replace('#' + utilQsString(hash, true)); + } + }, + + selectImage: function (context, id) { + let that = this; + + let d = that.cachedImage(id); + that.setActiveImage(d); + that.updateUrlImage(d.id); + + const viewerLink = `${viewerUrl}#pic=${d.id}&focus=pic`; + + let viewer = context.container() + .select('.photoviewer'); + + if (!viewer.empty()) viewer.datum(d); + + this.setStyles(context, null); + + if (!d) return this; + + let wrap = context.container() + .select('.photoviewer .panoramax-wrapper'); + + let attribution = wrap.selectAll('.photo-attribution').text(''); + + let line1 = attribution + .append('div') + .attr('class', 'attribution-row'); + + const hdDomId = utilUniqueDomId('panoramax-hd'); + + let label = line1 + .append('label') + .attr('for', hdDomId) + .attr('class', 'panoramax-hd'); + + label + .append('input') + .attr('type', 'checkbox') + .attr('id', hdDomId) + .property('checked', _isHD) + .on('click', (d3_event) => { + d3_event.stopPropagation(); + _isHD = !_isHD; + _definition = _isHD ? highDefinition : standardDefinition; + that.selectImage(context, d.id) + .showViewer(context); + }); + + label + .append('span') + .call(t.append('panoramax.hd')); + + if (d.capture_time) { + attribution + .append('span') + .attr('class', 'captured_at') + .text(localeDateString(d.capture_time)); + + attribution + .append('span') + .text('|'); + } + + attribution + .append('a') + .attr('class', 'report-photo') + .attr('href', 'mailto:signalement.ign@panoramax.fr') + .call(t.append('panoramax.report')); + + attribution + .append('span') + .text('|'); + + attribution + .append('a') + .attr('class', 'image-link') + .attr('target', '_blank') + .attr('href', viewerLink) + .text('panoramax.xyz'); + + getImageData(d.sequence_id, d.id).then(function(data){ + _currentScene = { + currentImage: null, + nextImage: null, + prevImage: null + }; + _currentScene.currentImage = data.assets[_definition]; + const nextIndex = data.links.findIndex(x => x.rel === 'next'); + const prevIndex = data.links.findIndex(x => x.rel === 'prev'); + + if (nextIndex !== -1){ + _currentScene.nextImage = data.links[nextIndex]; + } + if (prevIndex !== -1){ + _currentScene.prevImage = data.links[prevIndex]; + } + + d.image_path = _currentScene.currentImage.href; + + wrap + .selectAll('button.back') + .classed('hide', _currentScene.prevImage === null); + wrap + .selectAll('button.forward') + .classed('hide', _currentScene.nextImage === null); + + _currentFrame = d.isPano ? _pannellumFrame : _planeFrame; + + _currentFrame + .selectPhoto(d, true) + .showPhotoFrame(wrap); + + }); + + function localeDateString(s) { + if (!s) return null; + var options = { day: 'numeric', month: 'short', year: 'numeric' }; + var d = new Date(s); + if (isNaN(d.getTime())) return null; + return d.toLocaleDateString(localizer.localeCode(), options); + } + + if (d.account_id) { + attribution + .append('span') + .text('|'); + + let line2 = attribution + .append('span') + .attr('class', 'attribution-row'); + + getUsername(d.account_id).then(function(username){ + line2 + .append('span') + .attr('class', 'captured_by') + .text(t('panoramax.captured_by', {username})); + }); + } + + return this; + }, + + photoFrame: function() { + return _currentFrame; + }, + + ensureViewerLoaded: function(context) { + + let that = this; + + let imgWrap = context.container() + .select('#ideditor-viewer-panoramax-simple > img'); + + if (!imgWrap.empty()) { + imgWrap.remove(); + } + + if (_loadViewerPromise) return _loadViewerPromise; + + let wrap = context.container() + .select('.photoviewer') + .selectAll('.panoramax-wrapper') + .data([0]); + + let wrapEnter = wrap.enter() + .append('div') + .attr('class', 'photo-wrapper panoramax-wrapper') + .classed('hide', true) + .on('dblclick.zoom', null); + + wrapEnter + .append('div') + .attr('class', 'photo-attribution fillD'); + + const controlsEnter = wrapEnter + .append('div') + .attr('class', 'photo-controls-wrap') + .append('div') + .attr('class', 'photo-controls-panoramax'); + + controlsEnter + .append('button') + .classed('back', true) + .on('click.back', step(-1)) + .text('◄'); + + controlsEnter + .append('button') + .classed('forward', true) + .on('click.forward', step(1)) + .text('►'); + + // Register viewer resize handler + _loadViewerPromise = Promise.all([ + pannellumPhotoFrame.init(context, wrapEnter), + planePhotoFrame.init(context, wrapEnter) + ]).then(([pannellumPhotoFrame, planePhotoFrame]) => { + _pannellumFrame = pannellumPhotoFrame; + _pannellumFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); + _planeFrame = planePhotoFrame; + _planeFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged')); + }); + + function step(stepBy) { + return function () { + if (!_currentScene.currentImage) return; + + let nextId; + if (stepBy === 1) nextId = _currentScene.nextImage.id; + else nextId = _currentScene.prevImage.id; + + if (!nextId) return; + + const nextImage = _cache.images.forImageId[nextId]; + + context.map().centerEase(nextImage.loc); + + that.selectImage(context, nextImage.id); + }; + } + + return _loadViewerPromise; + }, + + showViewer: function (context) { + let wrap = context.container().select('.photoviewer') + .classed('hide', false); + let isHidden = wrap.selectAll('.photo-wrapper.panoramax-wrapper.hide').size(); + if (isHidden) { + wrap + .selectAll('.photo-wrapper:not(.panoramax-wrapper)') + .classed('hide', true); + wrap + .selectAll('.photo-wrapper.panoramax-wrapper') + .classed('hide', false); + } + return this; + }, + + hideViewer: function (context) { + let viewer = context.container().select('.photoviewer'); + if (!viewer.empty()) viewer.datum(null); + this.updateUrlImage(null); + viewer + .classed('hide', true) + .selectAll('.photo-wrapper') + .classed('hide', true); + context.container().selectAll('.viewfield-group, .sequence, .icon-sign') + .classed('currentView', false); + this.setActiveImage(); + return this.setStyles(context, null); + }, + + cache: function() { + return _cache; + } +}; diff --git a/modules/services/plane_photo.js b/modules/services/plane_photo.js index 980d01c22..4261ed085 100644 --- a/modules/services/plane_photo.js +++ b/modules/services/plane_photo.js @@ -85,16 +85,15 @@ export default { return this; }, - selectPhoto: function (data, keepOrientation) { + selectPhoto: function (data) { dispatch.call('viewerChanged'); + loadImage(_photo, ''); loadImage(_photo, data.image_path) .then(() => { - if (!keepOrientation) { - imgZoom = zoomBeahvior(); - _wrapper.call(imgZoom); - _wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0)); - } + imgZoom = zoomBeahvior(); + _wrapper.call(imgZoom); + _wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0)); }); return this; }, diff --git a/modules/svg/index.js b/modules/svg/index.js index c7fe6ca0c..9caffc5ed 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -29,3 +29,4 @@ export { svgTouch } from './touch.js'; export { svgTurns } from './turns.js'; export { svgVertices } from './vertices.js'; export { svgMapilioImages } from './mapilio_images.js'; +export { svgPanoramaxImages } from './panoramax_images.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index f186a4975..518bc5ce8 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -15,6 +15,7 @@ import { svgMapillarySigns } from './mapillary_signs'; import { svgMapillaryMapFeatures } from './mapillary_map_features'; import { svgKartaviewImages } from './kartaview_images'; import { svgMapilioImages } from './mapilio_images'; +import { svgPanoramaxImages } from './panoramax_images'; import { svgOsm } from './osm'; import { svgNotes } from './notes'; import { svgTouch } from './touch'; @@ -39,6 +40,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: 'panoramax', layer: svgPanoramaxImages(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) }, diff --git a/modules/svg/mapilio_images.js b/modules/svg/mapilio_images.js index ac62b52ef..70c56775a 100644 --- a/modules/svg/mapilio_images.js +++ b/modules/svg/mapilio_images.js @@ -229,7 +229,7 @@ export function svgMapilioImages(projection, context, dispatch) { svgMapilioImages.enabled = _; if (svgMapilioImages.enabled) { showLayer(); - context.photos().on('change.mapilio_images', null); + context.photos().on('change.mapilio_images', update); } else { hideLayer(); context.photos().on('change.mapilio_images', null); diff --git a/modules/svg/panoramax_images.js b/modules/svg/panoramax_images.js new file mode 100644 index 000000000..20f0df37d --- /dev/null +++ b/modules/svg/panoramax_images.js @@ -0,0 +1,382 @@ +import _throttle from 'lodash-es/throttle'; + +import { select as d3_select } from 'd3-selection'; +import { services } from '../services'; +import {svgPath, svgPointTransform} from './helpers'; + + +export function svgPanoramaxImages(projection, context, dispatch) { + const throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + const imageMinZoom = 15; + const lineMinZoom = 10; + const viewFieldZoomLevel = 18; + let layer = d3_select(null); + let _panoramax; + let _viewerYaw = 0; + let _selectedSequence; + let _activeUsernameFilter; + let _activeIds; + + function init() { + if (svgPanoramaxImages.initialized) return; + svgPanoramaxImages.enabled = false; + svgPanoramaxImages.initialized = true; + } + + + function getService() { + if (services.panoramax && !_panoramax) { + _panoramax = services.panoramax; + _panoramax.event + .on('viewerChanged', viewerChanged) + .on('loadedLines', throttledRedraw) + .on('loadedImages', throttledRedraw); + } else if (!services.panoramax && _panoramax) { + _panoramax = null; + } + + return _panoramax; + } + + async function filterImages(images) { + const showsPano = context.photos().showsPanoramic(); + const showsFlat = context.photos().showsFlat(); + const fromDate = context.photos().fromDate(); + const toDate = context.photos().toDate(); + const username = context.photos().usernames(); + + const service = getService(); + + if (!showsPano || !showsFlat) { + images = images.filter(function(image) { + if (image.isPano) return showsPano; + return showsFlat; + }); + } + if (fromDate) { + images = images.filter(function(image) { + return new Date(image.capture_time).getTime() >= new Date(fromDate).getTime(); + }); + } + if (toDate) { + images = images.filter(function(image) { + return new Date(image.capture_time).getTime() <= new Date(toDate).getTime(); + }); + } + if (username && service) { + if (_activeUsernameFilter !== username) { + _activeUsernameFilter = username; + + const tempIds = await service.getUserIds(username); + + _activeIds = {}; + tempIds.forEach(id => { + _activeIds[id] = true; + }); + } + + images = images.filter(function(image) { + return _activeIds[image.account_id]; + }); + } + + return images; + } + + async function filterSequences(sequences) { + const showsPano = context.photos().showsPanoramic(); + const showsFlat = context.photos().showsFlat(); + const fromDate = context.photos().fromDate(); + const toDate = context.photos().toDate(); + const username = context.photos().usernames(); + + const service = getService(); + + if (!showsPano || !showsFlat) { + sequences = sequences.filter(function(sequence) { + if (sequence.properties.type === 'equirectangular') return showsPano; + return showsFlat; + }); + } + if (fromDate) { + sequences = sequences.filter(function(sequence) { + return new Date(sequence.properties.date).getTime() >= new Date(fromDate).getTime().toString(); + }); + } + if (toDate) { + sequences = sequences.filter(function(sequence) { + return new Date(sequence.properties.date).getTime() <= new Date(toDate).getTime().toString(); + }); + } + if (username && service) { + if (_activeUsernameFilter !== username) { + _activeUsernameFilter = username; + + const tempIds = await service.getUserIds(username); + + _activeIds = {}; + tempIds.forEach(id => { + _activeIds[id] = true; + }); + } + + sequences = sequences.filter(function(sequence) { + return _activeIds[sequence.properties.account_id]; + }); + } + + return sequences; + } + + function showLayer() { + const service = getService(); + if (!service) return; + + editOn(); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', function () { dispatch.call('change'); }); + } + + + function hideLayer() { + throttledRedraw.cancel(); + + layer + .transition() + .duration(250) + .style('opacity', 0) + .on('end', editOff); + } + + function transform(d) { + let t = svgPointTransform(projection)(d); + var rot = d.heading + _viewerYaw; + if (rot) { + t += ' rotate(' + Math.floor(rot) + ',0,0)'; + } + return t; + } + + + function editOn() { + layer.style('display', 'block'); + } + + + function editOff() { + layer.selectAll('.viewfield-group').remove(); + layer.style('display', 'none'); + } + + 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() { + service + .selectImage(context, image.id) + .showViewer(context); + }); + + context.map().centerEase(image.loc); + } + + function mouseover(d3_event, image) { + const service = getService(); + if (service) service.setStyles(context, image); + } + + + function mouseout() { + const service = getService(); + if (service) service.setStyles(context, null); + } + + async function update() { + const zoom = ~~context.map().zoom(); + const showViewfields = (zoom >= viewFieldZoomLevel); + + const service = getService(); + let sequences = (service ? service.sequences(projection, zoom) : []); + let images = (service ? service.images(projection) : []); + + images = await filterImages(images); + sequences = await filterSequences(sequences, service); + + let traces = layer.selectAll('.sequences').selectAll('.sequence') + .data(sequences, function(d) { return d.id; }); + + // exit + traces.exit() + .remove(); + + traces.enter() + .append('path') + .attr('class', 'sequence') + .merge(traces) + .attr('d', svgPath(projection).geojson); + + + const groups = layer.selectAll('.markers').selectAll('.viewfield-group') + .data(images, function(d) { return d.id; }); + + // exit + groups.exit() + .remove(); + + // enter + const groupsEnter = groups.enter() + .append('g') + .attr('class', 'viewfield-group') + .on('mouseenter', mouseover) + .on('mouseleave', mouseout) + .on('click', click); + + groupsEnter + .append('g') + .attr('class', 'viewfield-scale'); + + // update + const markers = groups + .merge(groupsEnter) + .sort(function(a, b) { + return b.loc[1] - a.loc[1]; // sort Y + }) + .attr('transform', transform) + .select('.viewfield-scale'); + + + markers.selectAll('circle') + .data([0]) + .enter() + .append('circle') + .attr('dx', '0') + .attr('dy', '0') + .attr('r', '6'); + + const viewfields = markers.selectAll('.viewfield') + .data(showViewfields ? [0] : []); + + viewfields.exit() + .remove(); + + viewfields.enter() + .insert('path', 'circle') + .attr('class', 'viewfield') + .attr('transform', 'scale(1.5,1.5),translate(-8, -13)') + .attr('d', viewfieldPath); + + function viewfieldPath() { + if (this.parentNode.__data__.isPano) { + return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0'; + } else { + return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'; + } + } + } + + function viewerChanged() { + const service = getService(); + if (!service) return; + + const frame = service.photoFrame(); + + // update viewfield rotation + _viewerYaw = frame.getYaw(); + + // avoid updating if the map is currently transformed + // e.g. during drags or easing. + if (context.map().isTransformed()) return; + + layer.selectAll('.viewfield-group.currentView') + .attr('transform', transform); + } + + + function drawImages(selection) { + + const enabled = svgPanoramaxImages.enabled; + const service = getService(); + + layer = selection.selectAll('.layer-panoramax') + .data(service ? [0] : []); + + layer.exit() + .remove(); + + const layerEnter = layer.enter() + .append('g') + .attr('class', 'layer-panoramax') + .style('display', enabled ? 'block' : 'none'); + + layerEnter + .append('g') + .attr('class', 'sequences'); + + layerEnter + .append('g') + .attr('class', 'markers'); + + layer = layerEnter + .merge(layer); + + if (enabled) { + let zoom = ~~context.map().zoom(); + if (service){ + if (zoom >= imageMinZoom) { + editOn(); + update(); + service.loadImages(projection); + } else if (zoom >= lineMinZoom) { + editOn(); + update(); + service.loadLines(projection); + } else { + editOff(); + } + } else { + editOff(); + } + } + } + + drawImages.enabled = function(_) { + if (!arguments.length) return svgPanoramaxImages.enabled; + svgPanoramaxImages.enabled = _; + if (svgPanoramaxImages.enabled) { + showLayer(); + context.photos().on('change.panoramax_images', update); + } else { + hideLayer(); + context.photos().on('change.panoramax_images', null); + } + dispatch.call('change'); + return this; + }; + + + drawImages.supported = function() { + return !!getService(); + }; + + drawImages.rendered = function(zoom) { + return zoom >= lineMinZoom; + }; + + + init(); + return drawImages; +} diff --git a/modules/ui/photoviewer.js b/modules/ui/photoviewer.js index 71cbf2da1..b728750d3 100644 --- a/modules/ui/photoviewer.js +++ b/modules/ui/photoviewer.js @@ -28,6 +28,7 @@ export function uiPhotoviewer(context) { if (services.mapillary) { services.mapillary.hideViewer(context); } if (services.kartaview) { services.kartaview.hideViewer(context); } if (services.mapilio) { services.mapilio.hideViewer(context); } + if (services.panoramax) { services.panoramax.hideViewer(context); } if (services.vegbilder) { services.vegbilder.hideViewer(context); } }) .append('div') diff --git a/test/spec/services/panoramax.js b/test/spec/services/panoramax.js new file mode 100644 index 000000000..24c7176f2 --- /dev/null +++ b/test/spec/services/panoramax.js @@ -0,0 +1,115 @@ +describe('iD.servicePanoramax', function() { + var dimensions = [64, 64]; + var context, panoramax; + + before(function() { + iD.services.panoramax = iD.servicePanoramax; + fetchMock.reset(); + }); + + after(function() { + delete iD.services.panoramax; + }); + + beforeEach(function() { + context = iD.coreContext().assetPath('../dist/').init(); + context.projection + .scale(iD.geoZoomToScale(14)) + .translate([-116508, 0]) // 10,0 + .clipExtent([[0,0], dimensions]); + + panoramax = iD.services.panoramax; + panoramax.reset(); + fetchMock.reset(); + }); + + afterEach(function() { + fetchMock.reset(); + }); + + + describe('#init', function() { + it('Initializes cache one time', function() { + var cache = panoramax.cache(); + expect(cache).to.have.property('images'); + expect(cache).to.have.property('sequences'); + + panoramax.init(); + var cache2 = panoramax.cache(); + expect(cache).to.equal(cache2); + }); + }); + + describe('#reset', function() { + it('resets cache and image', function() { + panoramax.cache().foo = 'bar'; + panoramax.setActiveImage(context, {key: 'baz'}); + + panoramax.reset(); + expect(panoramax.cache()).to.not.have.property('foo'); + expect(panoramax.getActiveImage()).to.be.null; + }); + }); + + describe('#images', function() { + it('returns images in the visible map area', function() { + var features = [ + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } }, + { minX: 10, minY: 1, maxX: 10, maxY: 1, data: { id: '2', loc: [10,1], heading: 90, sequence_id: '100', account_id: '2' } } + ]; + + panoramax.cache().images.rtree.load(features); + var res = panoramax.images(context.projection); + + expect(res).to.deep.eql([ + { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' }, + { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } + ]); + }); + + it('limits results no more than 5 stacked images in one spot', function() { + var features = [ + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '2', loc: [10,0], heading: 90, sequence_id: '100', account_id: '2' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '3', loc: [10,0], heading: 90, sequence_id: '100', account_id: '3' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '4', loc: [10,0], heading: 90, sequence_id: '100', account_id: '4' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '5', loc: [10,0], heading: 90, sequence_id: '100', account_id: '5' } } + ]; + + panoramax.cache().images.rtree.load(features); + var res = panoramax.images(context.projection); + expect(res).to.have.length.of.at.most(5); + }); + }); + + + describe('#sequences', function() { + it('returns sequence linestrings in the visible map area', function() { + var features = [ + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } }, + { minX: 10, minY: 1, maxX: 10, maxY: 1, data: { id: '2', loc: [10,1], heading: 90, sequence_id: '100', account_id: '2' } } + ]; + + panoramax.cache().images.rtree.load(features); + panoramax.cache().sequences.lineString['100'] = { rotation: 0, images: [ features[0].data, features[1].data, features[2].data ] }; + + var res = panoramax.sequences(context.projection, 14); + expect(res).to.deep.eql([{ + rotation: 0, images: [features[0].data, features[1].data, features[2].data] + }]); + }); + }); + + describe('#selectedImage', function() { + it('sets and gets selected image', function() { + var d = { id: 'foo', sequence_id: '100'}; + panoramax.cache().images = { forImageId: { foo: d }}; + panoramax.selectImage(context, 'foo'); + expect(panoramax.getActiveImage()).to.eql(d); + }); + }); + +}); diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index e974b3eee..390b59099 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; @@ -40,10 +40,11 @@ describe('iD.svgLayers', function () { expect(d3.select(nodes[10]).classed('kartaview')).to.be.true; expect(d3.select(nodes[11]).classed('mapilio')).to.be.true; expect(d3.select(nodes[12]).classed('vegbilder')).to.be.true; - expect(d3.select(nodes[13]).classed('local-photos')).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[13]).classed('panoramax')).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; }); });