modules/services/mapillary.js (588 lines of code) (raw):

/* global mapillary:false */ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; import { Extent, Projection, Tiler, geoScaleToZoom } from '@id-sdk/math'; import { utilQsString, utilStringQs } from '@id-sdk/util'; import { VectorTile } from '@mapbox/vector-tile'; import Protobuf from 'pbf'; import RBush from 'rbush'; import { utilRebind } from '../util'; const accessToken = 'MLY|3376030635833192|f13ab0bdf6b2f7b99e0d8bd5868e1d88'; const apiUrl = 'https://graph.mapillary.com/'; const baseTileUrl = 'https://tiles.mapillary.com/maps/vtp'; const mapFeatureTileUrl = `${baseTileUrl}/mly_map_feature_point/2/{z}/{x}/{y}?access_token=${accessToken}`; const tileUrl = `${baseTileUrl}/mly1_public/2/{z}/{x}/{y}?access_token=${accessToken}`; const trafficSignTileUrl = `${baseTileUrl}/mly_map_feature_traffic_sign/2/{z}/{x}/{y}?access_token=${accessToken}`; const viewercss = 'mapillary-js/mapillary.css'; const viewerjs = 'mapillary-js/mapillary.js'; const minZoom = 14; const dispatch = d3_dispatch('change', 'loadedImages', 'loadedSigns', 'loadedMapFeatures', 'bearingChanged', 'imageChanged'); const tiler = new Tiler().skipNullIsland(true); let _loadViewerPromise; let _mlyActiveImage; let _mlyCache; let _mlyFallback = false; let _mlyHighlightedDetection; let _mlyShowFeatureDetections = false; let _mlyShowSignDetections = false; let _mlyViewer; let _mlyViewerFilter = ['all']; // Load all data for the specified type from Mapillary vector tiles function loadTiles(which, url, maxZoom, projection) { // determine the needed tiles to cover the view const proj = new Projection().transform(projection.transform()).dimensions(projection.clipExtent()); const tiles = tiler.zoomRange(minZoom, maxZoom).getTiles(proj).tiles; 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) { throw new Error('No Data'); } loadTileDataToCache(data, tile, which); if (which === 'images') { dispatch.call('loadedImages'); } else if (which === 'signs') { dispatch.call('loadedSigns'); } else if (which === 'points') { dispatch.call('loadedMapFeatures'); } }) .catch(function() { cache.loaded[tileId] = true; delete cache.inflight[tileId]; }); } // Load the data from the vector tile into cache function loadTileDataToCache(data, tile, which) { const vectorTile = new VectorTile(new Protobuf(data)); let features, cache, layer, i, feature, loc, d; if (vectorTile.layers.hasOwnProperty('image')) { features = []; cache = _mlyCache.images; layer = vectorTile.layers.image; 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, captured_at: feature.properties.captured_at, ca: feature.properties.compass_angle, id: feature.properties.id, is_pano: feature.properties.is_pano, sequence_id: feature.properties.sequence_id, }; 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('sequence')) { features = []; cache = _mlyCache.sequences; layer = vectorTile.layers.sequence; 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 (vectorTile.layers.hasOwnProperty('point')) { features = []; cache = _mlyCache[which]; layer = vectorTile.layers.point; 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, id: feature.properties.id, first_seen_at: feature.properties.first_seen_at, last_seen_at: feature.properties.last_seen_at, value: feature.properties.value }; 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('traffic_sign')) { features = []; cache = _mlyCache[which]; layer = vectorTile.layers.traffic_sign; 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, id: feature.properties.id, first_seen_at: feature.properties.first_seen_at, last_seen_at: feature.properties.last_seen_at, value: feature.properties.value }; features.push({ minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d }); } if (cache.rtree) { cache.rtree.load(features); } } } // Get data from the API function loadData(url) { return fetch(url) .then(function(response) { if (!response.ok) { throw new Error(response.status + ' ' + response.statusText); } return response.json(); }) .then(function(result) { if (!result) { return []; } return result.data || []; }); } // 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 proj = new Projection().transform(projection.transform()).dimensions(projection.clipExtent()); const tiles = tiler.zoomRange(z2).getTiles(proj).tiles; return tiles.map(tile => tile.wgs84Extent); } // 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(d => d.data); return (found.length ? result.concat(found) : result); }, []); } export default { // Initialize Mapillary 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: {} }, image_detections: { forImageId: {} }, signs: { rtree: new RBush() }, points: { rtree: new RBush() }, 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); }, // Get visible traffic signs signs: function(projection) { const limit = 5; return searchLimited(limit, projection, _mlyCache.signs.rtree); }, // Get visible map (point) features mapFeatures: function(projection) { const limit = 5; return searchLimited(limit, projection, _mlyCache.points.rtree); }, // Get cached image by id cachedImage: function(imageId) { return _mlyCache.images.forImageId[imageId]; }, // 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 = new Extent(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; }, // Load images in the visible area loadImages: function(projection) { loadTiles('images', tileUrl, 14, projection); }, // Load traffic signs in the visible area loadSigns: function(projection) { loadTiles('signs', trafficSignTileUrl, 14, projection); }, // Load map (point) features in the visible area loadMapFeatures: function(projection) { loadTiles('points', mapFeatureTileUrl, 14, projection); }, // Return a promise that resolves when the image viewer (Mapillary JS) library has finished loading ensureViewerLoaded: function(context) { if (_loadViewerPromise) return _loadViewerPromise; // add mly-wrapper const wrap = context.container().select('.photoviewer') .selectAll('.mly-wrapper') .data([0]); wrap.enter() .append('div') .attr('id', 'ideditor-mly') .attr('class', 'photo-wrapper mly-wrapper') .classed('hide', true); const that = this; _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 mapillary-viewercss head.selectAll('#ideditor-mapillary-viewercss') .data([0]) .enter() .append('link') .attr('id', 'ideditor-mapillary-viewercss') .attr('rel', 'stylesheet') .attr('crossorigin', 'anonymous') .attr('href', context.asset(viewercss)) .on('load.serviceMapillary', loaded) .on('error.serviceMapillary', function() { reject(); }); // load mapillary-viewerjs head.selectAll('#ideditor-mapillary-viewerjs') .data([0]) .enter() .append('script') .attr('id', 'ideditor-mapillary-viewerjs') .attr('crossorigin', 'anonymous') .attr('src', context.asset(viewerjs)) .on('load.serviceMapillary', loaded) .on('error.serviceMapillary', function() { reject(); }); }) .catch(function() { _loadViewerPromise = null; }) .then(function() { that.initViewer(context); }); return _loadViewerPromise; }, // Load traffic sign image sprites loadSignResources: function(context) { context.ui().svgDefs.addSprites(['mapillary-sprite'], false /* don't override colors */ ); return this; }, // Load map (point) feature image sprites loadObjectResources: function(context) { context.ui().svgDefs.addSprites(['mapillary-object-sprite'], false /* don't override colors */ ); return this; }, // Remove previous detections in image viewer resetTags: function() { if (_mlyViewer && !_mlyFallback) { _mlyViewer.getComponent('tag').removeAll(); } }, // Show map feature detections in image viewer showFeatureDetections: function(value) { _mlyShowFeatureDetections = value; if (!_mlyShowFeatureDetections && !_mlyShowSignDetections) { this.resetTags(); } }, // Show traffic sign detections in image viewer showSignDetections: function(value) { _mlyShowSignDetections = value; if (!_mlyShowFeatureDetections && !_mlyShowSignDetections) { this.resetTags(); } }, // Apply filter to image viewer filterViewer: function(context) { const showsPano = context.photos().showsPanoramic(); const showsFlat = context.photos().showsFlat(); const fromDate = context.photos().fromDate(); const toDate = context.photos().toDate(); const filter = ['all']; if (!showsPano) filter.push([ '!=', 'cameraType', 'spherical' ]); if (!showsFlat && showsPano) filter.push(['==', 'pano', true]); if (fromDate) { filter.push(['>=', 'capturedAt', new Date(fromDate).getTime()]); } if (toDate) { filter.push(['>=', 'capturedAt', new Date(toDate).getTime()]); } if (_mlyViewer) { _mlyViewer.setFilter(filter); } _mlyViewerFilter = filter; return filter; }, // Make the image viewer visible showViewer: function(context) { const wrap = context.container().select('.photoviewer') .classed('hide', false); const isHidden = wrap.selectAll('.photo-wrapper.mly-wrapper.hide').size(); if (isHidden && _mlyViewer) { wrap .selectAll('.photo-wrapper:not(.mly-wrapper)') .classed('hide', true); wrap .selectAll('.photo-wrapper.mly-wrapper') .classed('hide', false); _mlyViewer.resize(); } return this; }, // Hide the image viewer and resets map markers hideViewer: function(context) { _mlyActiveImage = null; if (!_mlyFallback && _mlyViewer) { _mlyViewer.getComponent('sequence').stop(); } const viewer = context.container().select('.photoviewer'); if (!viewer.empty()) viewer.datum(null); viewer .classed('hide', true) .selectAll('.photo-wrapper') .classed('hide', true); this.updateUrlImage(null); dispatch.call('imageChanged'); dispatch.call('loadedMapFeatures'); dispatch.call('loadedSigns'); return this.setStyles(context, null); }, // Update the URL with current image id updateUrlImage: function(imageId) { if (!window.mocha) { const hash = utilStringQs(window.location.hash); if (imageId) { hash.photo = 'mapillary/' + imageId; } else { delete hash.photo; } window.location.replace('#' + utilQsString(hash, true)); } }, // Highlight the detection in the viewer that is related to the clicked map feature highlightDetection: function(detection) { if (detection) { _mlyHighlightedDetection = detection.id; } return this; }, // Initialize image viewer (Mapillar JS) initViewer: function(context) { const that = this; if (!window.mapillary) return; const opts = { accessToken: accessToken, component: { cover: false, keyboard: false, tag: true }, container: 'ideditor-mly', }; // Disable components requiring WebGL support if (!mapillary.isSupported() && mapillary.isFallbackSupported()) { _mlyFallback = true; opts.component = { cover: false, direction: false, imagePlane: false, keyboard: false, mouse: false, sequence: false, tag: false, image: true, // fallback navigation: true // fallback }; } _mlyViewer = new mapillary.Viewer(opts); _mlyViewer.on('image', imageChanged); _mlyViewer.on('bearing', bearingChanged); if (_mlyViewerFilter) { _mlyViewer.setFilter(_mlyViewerFilter); } // Register viewer resize handler context.ui().photoviewer.on('resize.mapillary', function() { if (_mlyViewer) _mlyViewer.resize(); }); // imageChanged: called after the viewer has changed images and is ready. function imageChanged(node) { that.resetTags(); const image = node.image; that.setActiveImage(image); that.setStyles(context, null); const loc = [image.originalLngLat.lng, image.originalLngLat.lat]; context.map().centerEase(loc); that.updateUrlImage(image.id); if (_mlyShowFeatureDetections || _mlyShowSignDetections) { that.updateDetections(image.id, `${apiUrl}/${image.id}/detections?access_token=${accessToken}&fields=id,image,geometry,value`); } dispatch.call('imageChanged'); } // bearingChanged: called when the bearing changes in the image viewer. function bearingChanged(e) { dispatch.call('bearingChanged', undefined, e); } }, // Move to an image selectImage: function(context, imageId) { if (_mlyViewer && imageId) { _mlyViewer.moveTo(imageId) .catch(function(e) { console.error('mly3', e); // eslint-disable-line no-console }); } return this; }, // Return the currently displayed image getActiveImage: function() { return _mlyActiveImage; }, // Return a list of detection objects for the given id getDetections: function(id) { return loadData(`${apiUrl}/${id}/detections?access_token=${accessToken}&fields=id,value,image`); }, // Set the currently visible image setActiveImage: function(image) { if (image) { _mlyActiveImage = { ca: image.originalCompassAngle, id: image.id, loc: [image.originalLngLat.lng, image.originalLngLat.lat], is_pano: image.cameraType === 'spherical', sequence_id: image.sequenceId }; } 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; context.container().selectAll('.layer-mapillary .viewfield-group') .classed('highlighted', function(d) { return (d.sequence_id === selectedSequenceId) || (d.id === hoveredImageId); }) .classed('hovered', function(d) { return d.id === hoveredImageId; }); context.container().selectAll('.layer-mapillary .sequence') .classed('highlighted', function(d) { return d.properties.id === hoveredSequenceId; }) .classed('currentView', function(d) { return d.properties.id === selectedSequenceId; }); return this; }, // Get detections for the current image and shows them in the image viewer updateDetections: function(imageId, url) { if (!_mlyViewer || _mlyFallback) return; if (!imageId) return; const cache = _mlyCache.image_detections; if (cache.forImageId[imageId]) { showDetections(_mlyCache.image_detections.forImageId[imageId]); } else { loadData(url) .then(detections => { detections.forEach(function(detection) { if (!cache.forImageId[imageId]) { cache.forImageId[imageId] = []; } cache.forImageId[imageId].push({ geometry: detection.geometry, id: detection.id, image_id: imageId, value:detection.value }); }); showDetections(_mlyCache.image_detections.forImageId[imageId] || []); }); } // Create a tag for each detection and shows it in the image viewer function showDetections(detections) { const tagComponent = _mlyViewer.getComponent('tag'); detections.forEach(function(data) { const tag = makeTag(data); if (tag) { tagComponent.add([tag]); } }); } // Create a Mapillary JS tag object function makeTag(data) { const valueParts = data.value.split('--'); if (!valueParts.length) return; let tag; let text; let color = 0xffffff; if (_mlyHighlightedDetection === data.id) { color = 0xffff00; text = valueParts[1]; if (text === 'flat' || text === 'discrete' || text === 'sign') { text = valueParts[2]; } text = text.replace(/-/g, ' '); text = text.charAt(0).toUpperCase() + text.slice(1); _mlyHighlightedDetection = null; } var decodedGeometry = window.atob(data.geometry); var uintArray = new Uint8Array(decodedGeometry.length); for (var i = 0; i < decodedGeometry.length; i++) { uintArray[i] = decodedGeometry.charCodeAt(i); } const tile = new VectorTile(new Protobuf(uintArray.buffer)); const layer = tile.layers['mpy-or']; const geometries = layer.feature(0).loadGeometry(); const polygon = geometries.map(ring => ring.map(point => [point.x / layer.extent, point.y / layer.extent])); tag = new mapillary.OutlineTag( data.id, new mapillary.PolygonGeometry(polygon[0]), { text: text, textColor: color, lineColor: color, lineWidth: 2, fillColor: color, fillOpacity: 0.3, } ); return tag; } }, // Return the current cache cache: function() { return _mlyCache; } };