diff --git a/CHANGELOG.md b/CHANGELOG.md index dd621a530..d2927eb39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ _Breaking developer changes, which may affect downstream projects or sites that # Unreleased (2.27.0-dev) +#### :mega: Release Highlights +* Add [_Mapilio_](https://mapilio.com/openstreetmap) as new street-level imagery provider ([#9664], thanks [@channel-s]) #### :tada: New Features #### :sparkles: Usability & Accessibility * Show tag reference information for the currently filled-in tag value in UI fields (if available), instead of only showing the more generic _key_ documentation of the field ([#9786]) @@ -55,6 +57,8 @@ _Breaking developer changes, which may affect downstream projects or sites that [#8997]: https://github.com/openstreetmap/iD/issues/8997 [#9786]: https://github.com/openstreetmap/iD/issues/9786 +[#9664]: https://github.com/openstreetmap/iD/pull/9664 +[@channel-s]: https://github.com/channel-s # 2.26.2 diff --git a/css/60_photos.css b/css/60_photos.css index b3406b435..88baa03a0 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -244,6 +244,43 @@ stroke: #20c4ff; } +/* Mapilio Image Layer */ +.layer-mapilio { + pointer-events: none; +} +.layer-mapilio .viewfield-group * { + fill: #0056f1; + stroke: #ffffff; + stroke-opacity: .6; + fill-opacity: .6; +} +.layer-mapilio .sequence { + stroke: #0056f1; +} +.photo-controls-mapilio { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.photo-controls-mapilio button { + padding:0 6px; + pointer-events: initial; +} + +.ideditor .mapilio-wrapper { + position: relative; + background-color: #000; + background-image: url(img/loader-black.gif); + background-position: center; + background-repeat: no-repeat; +} +#ideditor-viewer-mapilio { + width: 100%; + height: 100%; + transform-origin: 0 0; +} /* Streetside Viewer (pannellum) */ .ms-wrapper .photo-attribution .image-link { @@ -295,6 +332,10 @@ label.streetside-hires { margin: 0 5px; } +.pnlm-zoom-controls { + margin-top: 6px; +} + /* Mapillary viewer */ #ideditor-mly .domRenderer .TagSymbol { diff --git a/data/core.yaml b/data/core.yaml index 37e18ea69..03fc38bee 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -1432,6 +1432,9 @@ en: kartaview: title: KartaView view_on_kartaview: "View this image on KartaView" + mapilio: + title: Mapilio + tooltip: "Street-level photos from Mapilio" note: note: Note title: Edit note diff --git a/modules/renderer/background.js b/modules/renderer/background.js index e6ee5d051..236dfc517 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -251,7 +251,8 @@ export function rendererBackground(context) { mapillary: 'Mapillary Images', 'mapillary-map-features': 'Mapillary Map Features', 'mapillary-signs': 'Mapillary Signs', - kartaview: 'KartaView Images' + kartaview: 'KartaView Images', + mapilio: 'Mapilio Images' }; for (let layerID in photoOverlayLayers) { diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index ee2705b84..227b4b20a 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']; + var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio']; var _allPhotoTypes = ['flat', 'panoramic']; var _shownPhotoTypes = _allPhotoTypes.slice(); // shallow copy var _dateFilters = ['fromDate', 'toDate']; diff --git a/modules/services/index.js b/modules/services/index.js index 6b1784da7..108412f4e 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -13,6 +13,7 @@ import serviceTaginfo from './taginfo'; import serviceVectorTile from './vector_tile'; import serviceWikidata from './wikidata'; import serviceWikipedia from './wikipedia'; +import serviceMapilio from './mapilio'; export let services = { @@ -30,7 +31,8 @@ export let services = { taginfo: serviceTaginfo, vectorTile: serviceVectorTile, wikidata: serviceWikidata, - wikipedia: serviceWikipedia + wikipedia: serviceWikipedia, + mapilio: serviceMapilio }; export { @@ -48,5 +50,6 @@ export { serviceTaginfo, serviceVectorTile, serviceWikidata, - serviceWikipedia + serviceWikipedia, + serviceMapilio }; diff --git a/modules/services/mapilio.js b/modules/services/mapilio.js new file mode 100644 index 000000000..3f69a40b8 --- /dev/null +++ b/modules/services/mapilio.js @@ -0,0 +1,605 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { select as d3_select } from 'd3-selection'; +import { zoom as d3_zoom, zoomIdentity as d3_zoomIdentity } from 'd3-zoom'; + +import Protobuf from 'pbf'; +import RBush from 'rbush'; +import { VectorTile } from '@mapbox/vector-tile'; + +import { utilRebind, utilTiler, utilQsString, utilStringQs, utilSetTransform } from '../util'; +import {geoExtent, geoScaleToZoom} from '../geo'; +import {localizer} from '../core/localizer'; + +const apiUrl = 'https://end.mapilio.com'; +const imageBaseUrl = 'https://cdn.mapilio.com/im'; +const baseTileUrl = 'https://geo.mapilio.com/geoserver/gwc/service/wmts?REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&LAYER=mapilio:'; +const pointLayer = 'map_points'; +const lineLayer = 'map_roads_line'; +const tileStyle = '&STYLE=&TILEMATRIX=EPSG:900913:{z}&TILEMATRIXSET=EPSG:900913&FORMAT=application/vnd.mapbox-vector-tile&TILECOL={x}&TILEROW={y}'; + +const minZoom = 14; +const dispatch = d3_dispatch('loadedImages', 'loadedLines'); +const imgZoom = d3_zoom() + .extent([[0, 0], [320, 240]]) + .translateExtent([[0, 0], [320, 240]]) + .scaleExtent([1, 15]); +const pannellumViewerCSS = 'pannellum-streetside/pannellum.css'; +const pannellumViewerJS = 'pannellum-streetside/pannellum.js'; +const resolution = 1080; + +let _mlyActiveImage; +let _mlyCache; +let _loadViewerPromise; +let _pannellumViewer; +let _mlySceneOptions = { + showFullscreenCtrl: false, + autoLoad: true, + yaw: 0, + minHfov: 10, + maxHfov: 90, + hfov: 60, +}; +let _currScene = 0; + + +// 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 Mapilio 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 = _mlyCache.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 + } + }); +} + + +// Load the data from the vector tile into cache +function loadTileDataToCache(data, tile) { + const vectorTile = new VectorTile(new Protobuf(data)); + let features, + cache, + layer, + i, + feature, + loc, + d; + if (vectorTile.layers.hasOwnProperty(pointLayer)) { + features = []; + cache = _mlyCache.images; + layer = vectorTile.layers[pointLayer]; + + 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; + + let resolutionArr = feature.properties.resolution.split('x'); + let sourceWidth = Math.max(resolutionArr[0], resolutionArr[1]); + let sourceHeight = Math.min(resolutionArr[0] ,resolutionArr[1]); + let isPano = sourceWidth % sourceHeight === 0; + + d = { + loc: loc, + capture_time: feature.properties.capture_time, + id: feature.properties.id, + sequence_id: feature.properties.sequence_uuid, + heading: feature.properties.heading, + resolution: feature.properties.resolution, + isPano: isPano + }; + cache.forImageId[d.id] = d; + features.push({ + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + }); + } + if (cache.rtree) { + cache.rtree.load(features); + } + } + + if (vectorTile.layers.hasOwnProperty(lineLayer)) { + cache = _mlyCache.sequences; + layer = vectorTile.layers[lineLayer]; + + 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.sequence_uuid]) { + cache.lineString[feature.properties.sequence_uuid].push(feature); + } else { + cache.lineString[feature.properties.sequence_uuid] = [feature]; + } + } + } + +} + +function getImageData(imageId, sequenceId) { + + return fetch(apiUrl + `/api/sequence-detail?sequence_uuid=${sequenceId}`, {method: 'GET'}) + .then(function (response) { + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + return response.json(); + }) + .then(function (data) { + let index = data.data.findIndex((feature) => feature.id === imageId); + const {filename, uploaded_hash} = data.data[index]; + _mlySceneOptions.panorama = imageBaseUrl + '/' + uploaded_hash + '/' + filename + '/' + resolution; + }); +} + + +export default { + // Initialize Mapilio + init: function() { + if (!_mlyCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + // Reset cache and state + reset: function() { + if (_mlyCache) { + Object.values(_mlyCache.requests.inflight).forEach(function(request) { request.abort(); }); + } + + _mlyCache = { + images: { rtree: new RBush(), forImageId: {} }, + sequences: { rtree: new RBush(), lineString: {} }, + requests: { loaded: {}, inflight: {} } + }; + + _mlyActiveImage = null; + }, + + // Get visible images + images: function(projection) { + const limit = 5; + return searchLimited(limit, projection, _mlyCache.images.rtree); + }, + + cachedImage: function(imageKey) { + return _mlyCache.images.forImageId[imageKey]; + }, + + + // Load images in the visible area + loadImages: function(projection) { + let url = baseTileUrl + pointLayer + tileStyle; + loadTiles('images', url, 14, projection); + }, + + // Load line in the visible area + loadLines: function(projection) { + let url = baseTileUrl + lineLayer + tileStyle; + loadTiles('line', url, 14, projection); + }, + + // Get visible sequences + sequences: function(projection) { + 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 = []; + + _mlyCache.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 (_mlyCache.sequences.lineString[sequenceId]) { + lineStrings = lineStrings.concat(_mlyCache.sequences.lineString[sequenceId]); + } + }); + + return lineStrings; + }, + + // Set the currently visible image + setActiveImage: function(image) { + if (image) { + _mlyActiveImage = { + id: image.id, + sequence_id: image.sequence_id + }; + } else { + _mlyActiveImage = null; + } + }, + + + // 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 = _mlyActiveImage && _mlyActiveImage.sequence_id; + const selectedImageId = _mlyActiveImage && _mlyActiveImage.id; + + const markers = context.container().selectAll('.layer-mapilio .viewfield-group'); + const sequences = context.container().selectAll('.layer-mapilio .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.properties.sequence_uuid === hoveredSequenceId; }) + .classed('currentView', function(d) { return d.properties.sequence_uuid === selectedSequenceId; }); + + return this; + }, + + updateUrlImage: function(imageKey) { + if (!window.mocha) { + var hash = utilStringQs(window.location.hash); + if (imageKey) { + hash.photo = 'mapilio/' + imageKey; + } else { + delete hash.photo; + } + window.location.replace('#' + utilQsString(hash, true)); + } + }, + + initViewer: function () { + if (!window.pannellum) return; + if (_pannellumViewer) return; + + _currScene += 1; + const sceneID = _currScene.toString(); + const options = { + 'default': { firstScene: sceneID }, + scenes: {} + }; + options.scenes[sceneID] = _mlySceneOptions; + + _pannellumViewer = window.pannellum.viewer('ideditor-viewer-mapilio', options); + }, + + selectImage: function (context, id) { + + let that = this; + + let d = this.cachedImage(id); + + this.setActiveImage(d); + + this.updateUrlImage(d.id); + + 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 .mapilio-wrapper'); + let attribution = wrap.selectAll('.photo-attribution').text(''); + + 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', 'image-link') + .attr('target', '_blank') + .attr('href', `https://mapilio.com/app?lat=${d.loc[1]}&lng=${d.loc[0]}&zoom=17&pId=${d.id}`) + .text('mapilio.com'); + + wrap + .transition() + .duration(100) + .call(imgZoom.transform, d3_zoomIdentity); + + wrap + .selectAll('img') + .remove(); + + getImageData(d.id,d.sequence_id).then(function () { + + if (d.isPano){ + if (!_pannellumViewer) { + that.initViewer(); + } else { + // make a new scene + _currScene += 1; + let sceneID = _currScene.toString(); + _pannellumViewer + .addScene(sceneID, _mlySceneOptions) + .loadScene(sceneID); + + // remove previous scene + if (_currScene > 2) { + sceneID = (_currScene - 1).toString(); + _pannellumViewer + .removeScene(sceneID); + } + } + } else { + // make non-panoramic photo viewer + that.initOnlyPhoto(context); + } + }); + + 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); + } + + return this; + }, + + initOnlyPhoto: function (context) { + + if (_pannellumViewer) { + _pannellumViewer.destroy(); + _pannellumViewer = null; + } + + let wrap = context.container().select('#ideditor-viewer-mapilio'); + + let imgWrap = wrap.select('img'); + + wrap.style('height','100%'); + + if (!imgWrap.empty()){ + imgWrap.attr('src',_mlySceneOptions.panorama); + } else { + wrap.append('img') + .attr('src',_mlySceneOptions.panorama); + } + + }, + + ensureViewerLoaded: function(context) { + + let that = this; + + let imgWrap = context.container().select('#ideditor-viewer-mapilio > img'); + + if (!imgWrap.empty()) { + imgWrap.remove(); + } + + if (_loadViewerPromise) return _loadViewerPromise; + + let wrap = context.container().select('.photoviewer').selectAll('.mapilio-wrapper') + .data([0]); + + let wrapEnter = wrap.enter() + .append('div') + .attr('class', 'photo-wrapper mapilio-wrapper') + .classed('hide', true) + .call(imgZoom.on('zoom', zoomPan)) + .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-mapilio'); + + controlsEnter + .append('button') + .on('click.back', step(-1)) + .text('◄'); + + controlsEnter + .append('button') + .on('click.forward', step(1)) + .text('►'); + + wrapEnter + .append('div') + .attr('id', 'ideditor-viewer-mapilio'); + + + // Register viewer resize handler + context.ui().photoviewer.on('resize.mapilio', () => { + if (_pannellumViewer) { + _pannellumViewer.resize(); + } + }); + + _loadViewerPromise = new Promise((resolve, reject) => { + let loadedCount = 0; + function loaded() { + loadedCount += 1; + + // wait until both files are loaded + if (loadedCount === 2) resolve(); + } + + const head = d3_select('head'); + + // load pannellum-viewercss + head.selectAll('#ideditor-mapilio-viewercss') + .data([0]) + .enter() + .append('link') + .attr('id', 'ideditor-mapilio-viewercss') + .attr('rel', 'stylesheet') + .attr('crossorigin', 'anonymous') + .attr('href', context.asset(pannellumViewerCSS)) + .on('load.serviceMapilio', loaded) + .on('error.serviceMapilio', function() { + reject(); + }); + + // load pannellum-viewerjs + head.selectAll('#ideditor-mapilio-viewerjs') + .data([0]) + .enter() + .append('script') + .attr('id', 'ideditor-mapilio-viewerjs') + .attr('crossorigin', 'anonymous') + .attr('src', context.asset(pannellumViewerJS)) + .on('load.serviceMapilio', loaded) + .on('error.serviceMapilio', function() { + reject(); + }); + }) + .catch(function() { + _loadViewerPromise = null; + }); + + function step(stepBy) { + return function () { + if (!_mlyActiveImage) return; + const imageId = _mlyActiveImage.id; + + const nextIndex = imageId + stepBy; + if (!nextIndex) return; + + const nextImage = _mlyCache.images.forImageId[nextIndex]; + + context.map().centerEase(nextImage.loc); + + that.selectImage(context, nextImage.id); + }; + } + + function zoomPan(d3_event) { + var t = d3_event.transform; + context.container().select('.photoviewer #ideditor-viewer-mapilio') + .call(utilSetTransform, t.x, t.y, t.k); + } + + return _loadViewerPromise; + }, + + showViewer:function (context) { + let wrap = context.container().select('.photoviewer') + .classed('hide', false); + + let isHidden = wrap.selectAll('.photo-wrapper.mapilio-wrapper.hide').size(); + + if (isHidden) { + wrap + .selectAll('.photo-wrapper:not(.mapilio-wrapper)') + .classed('hide', true); + + wrap + .selectAll('.photo-wrapper.mapilio-wrapper') + .classed('hide', false); + } + + return this; + }, + + /** + * hideViewer() + */ + 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); + }, + + // Return the current cache + cache: function() { + return _mlyCache; + } +}; diff --git a/modules/svg/index.js b/modules/svg/index.js index e2c94c316..798afe3f7 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -27,3 +27,4 @@ export { svgTagPattern } from './tag_pattern.js'; export { svgTouch } from './touch.js'; export { svgTurns } from './turns.js'; export { svgVertices } from './vertices.js'; +export { svgMapilioImages } from './mapilio_images.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 169805c4c..9f5e9a503 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -13,6 +13,7 @@ import { svgMapillaryPosition } from './mapillary_position'; import { svgMapillarySigns } from './mapillary_signs'; import { svgMapillaryMapFeatures } from './mapillary_map_features'; import { svgKartaviewImages } from './kartaview_images'; +import { svgMapilioImages } from './mapilio_images'; import { svgOsm } from './osm'; import { svgNotes } from './notes'; import { svgTouch } from './touch'; @@ -36,9 +37,10 @@ export function svgLayers(projection, context) { { id: 'mapillary-map-features', layer: svgMapillaryMapFeatures(projection, context, dispatch) }, { id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) }, { id: 'kartaview', layer: svgKartaviewImages(projection, context, dispatch) }, + { id: 'mapilio', layer: svgMapilioImages(projection, context, dispatch) }, { id: 'debug', layer: svgDebug(projection, context, dispatch) }, { id: 'geolocate', layer: svgGeolocate(projection, context, dispatch) }, - { id: 'touch', layer: svgTouch(projection, context, dispatch) } + { id: 'touch', layer: svgTouch(projection, context, dispatch) }, ]; diff --git a/modules/svg/mapilio_images.js b/modules/svg/mapilio_images.js new file mode 100644 index 000000000..1f4a679a8 --- /dev/null +++ b/modules/svg/mapilio_images.js @@ -0,0 +1,249 @@ +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 svgMapilioImages(projection, context, dispatch) { + const throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + const minZoom = 12; + let layer = d3_select(null); + let _mapilio; + const viewFieldZoomLevel = 18; + + + function init() { + if (svgMapilioImages.initialized) return; + svgMapilioImages.enabled = false; + svgMapilioImages.initialized = true; + } + + + function getService() { + if (services.mapilio && !_mapilio) { + _mapilio = services.mapilio; + _mapilio.event.on('loadedImages', throttledRedraw); + } else if (!services.mapilio && _mapilio) { + _mapilio = null; + } + + return _mapilio; + } + + + 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); + if (d.heading) { + t += ' rotate(' + Math.floor(d.heading) + ',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; + + service + .ensureViewerLoaded(context, image.id) + .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); + } + + function update() { + + const z = ~~context.map().zoom(); + const showViewfields = (z >= viewFieldZoomLevel); + + const service = getService(); + let sequences = (service ? service.sequences(projection) : []); + let images = (service ? service.images(projection) : []); + + let traces = layer.selectAll('.sequences').selectAll('.sequence') + .data(sequences, function(d) { return d.properties.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 drawImages(selection) { + const enabled = svgMapilioImages.enabled; + const service = getService(); + + layer = selection.selectAll('.layer-mapilio') + .data(service ? [0] : []); + + layer.exit() + .remove(); + + const layerEnter = layer.enter() + .append('g') + .attr('class', 'layer-mapilio') + .style('display', enabled ? 'block' : 'none'); + + layerEnter + .append('g') + .attr('class', 'sequences'); + + layerEnter + .append('g') + .attr('class', 'markers'); + + layer = layerEnter + .merge(layer); + + if (enabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + update(); + service.loadImages(projection); + service.loadLines(projection); + } else { + editOff(); + } + } + } + + + drawImages.enabled = function(_) { + if (!arguments.length) return svgMapilioImages.enabled; + svgMapilioImages.enabled = _; + if (svgMapilioImages.enabled) { + showLayer(); + context.photos().on('change.mapilio_images', null); + } else { + hideLayer(); + context.photos().on('change.mapilio_images', null); + } + dispatch.call('change'); + return this; + }; + + + drawImages.supported = function() { + return !!getService(); + }; + + + init(); + return drawImages; +} diff --git a/modules/ui/photoviewer.js b/modules/ui/photoviewer.js index 5c82d4cef..e25f5f6d3 100644 --- a/modules/ui/photoviewer.js +++ b/modules/ui/photoviewer.js @@ -24,6 +24,7 @@ export function uiPhotoviewer(context) { if (services.streetside) { services.streetside.hideViewer(context); } if (services.mapillary) { services.mapillary.hideViewer(context); } if (services.kartaview) { services.kartaview.hideViewer(context); } + if (services.mapilio) { services.mapilio.hideViewer(context); } }) .append('div') .call(svgIcon('#iD-icon-close')); diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index 2c54936d7..22c1da3ea 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(15); + expect(nodes.length).to.eql(16); 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; @@ -39,9 +39,10 @@ describe('iD.svgLayers', function () { expect(d3.select(nodes[9]).classed('mapillary-map-features')).to.be.true; expect(d3.select(nodes[10]).classed('mapillary-signs')).to.be.true; expect(d3.select(nodes[11]).classed('kartaview')).to.be.true; - expect(d3.select(nodes[12]).classed('debug')).to.be.true; - expect(d3.select(nodes[13]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[14]).classed('touch')).to.be.true; + expect(d3.select(nodes[12]).classed('mapilio')).to.be.true; + expect(d3.select(nodes[13]).classed('debug')).to.be.true; + expect(d3.select(nodes[14]).classed('geolocate')).to.be.true; + expect(d3.select(nodes[15]).classed('touch')).to.be.true; }); });