/* global Mapillary:false */ import _filter from 'lodash-es/filter'; import _find from 'lodash-es/find'; import _flatten from 'lodash-es/flatten'; import _forEach from 'lodash-es/forEach'; import _isEmpty from 'lodash-es/isEmpty'; import _map from 'lodash-es/map'; import _some from 'lodash-es/some'; import { range as d3_range } from 'd3-array'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { request as d3_request, json as d3_json } from 'd3-request'; import { select as d3_select, selectAll as d3_selectAll } from 'd3-selection'; import rbush from 'rbush'; import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; import { geoExtent } from '../geo'; import { svgIcon } from '../svg'; import { utilDetect } from '../util/detect'; import { utilQsString, utilRebind } from '../util'; var apibase = 'https://a.mapillary.com/v3/', viewercss = 'mapillary-js/mapillary.min.css', viewerjs = 'mapillary-js/mapillary.min.js', clientId = 'NzNRM2otQkR2SHJzaXJmNmdQWVQ0dzo1ZWYyMmYwNjdmNDdlNmVi', maxResults = 1000, tileZoom = 14, dispatch = d3_dispatch('loadedImages', 'loadedSigns'), mapillaryCache, mapillaryClicks, mapillaryImage, mapillarySignDefs, mapillarySignSprite, mapillaryViewer; function abortRequest(i) { i.abort(); } function nearNullIsland(x, y, z) { if (z >= 7) { var center = Math.pow(2, z - 1), width = Math.pow(2, z - 6), min = center - (width / 2), max = center + (width / 2) - 1; return x >= min && x <= max && y >= min && y <= max; } return false; } function maxPageAtZoom(z) { if (z < 15) return 2; if (z === 15) return 5; if (z === 16) return 10; if (z === 17) return 20; if (z === 18) return 40; if (z > 18) return 80; } function getTiles(projection) { var s = projection.scale() * 2 * Math.PI, z = Math.max(Math.log(s) / Math.log(2) - 8, 0), ts = 256 * Math.pow(2, z - tileZoom), origin = [ s / 2 - projection.translate()[0], s / 2 - projection.translate()[1]]; return d3_geoTile() .scaleExtent([tileZoom, tileZoom]) .scale(s) .size(projection.clipExtent()[1]) .translate(projection.translate())() .map(function(tile) { var x = tile[0] * ts - origin[0], y = tile[1] * ts - origin[1]; return { id: tile.toString(), xyz: tile, extent: geoExtent( projection.invert([x, y + ts]), projection.invert([x + ts, y]) ) }; }); } function loadTiles(which, url, projection) { var s = projection.scale() * 2 * Math.PI, currZoom = Math.floor(Math.max(Math.log(s) / Math.log(2) - 8, 0)); var tiles = getTiles(projection).filter(function(t) { return !nearNullIsland(t.xyz[0], t.xyz[1], t.xyz[2]); }); _filter(which.inflight, function(v, k) { var wanted = _find(tiles, function(tile) { return k === (tile.id + ',0'); }); if (!wanted) delete which.inflight[k]; return !wanted; }).map(abortRequest); tiles.forEach(function(tile) { loadNextTilePage(which, currZoom, url, tile); }); } function loadNextTilePage(which, currZoom, url, tile) { var cache = mapillaryCache[which], rect = tile.extent.rectangle(), maxPages = maxPageAtZoom(currZoom), nextPage = cache.nextPage[tile.id] || 0, nextURL = cache.nextURL[tile.id] || url + utilQsString({ per_page: maxResults, page: nextPage, client_id: clientId, bbox: [rect[0], rect[1], rect[2], rect[3]].join(','), }); if (nextPage > maxPages) return; var id = tile.id + ',' + String(nextPage); if (cache.loaded[id] || cache.inflight[id]) return; cache.inflight[id] = d3_request(nextURL) .mimeType('application/json') .response(function(xhr) { var linkHeader = xhr.getResponseHeader('Link'); if (linkHeader) { var pagination = parsePagination(xhr.getResponseHeader('Link')); if (pagination.next) { cache.nextURL[tile.id] = pagination.next; } } return JSON.parse(xhr.responseText); }) .get(function(err, data) { cache.loaded[id] = true; delete cache.inflight[id]; if (err || !data.features || !data.features.length) return; var features = data.features.map(function(feature) { var loc = feature.geometry.coordinates, d; if (which === 'images') { d = { loc: loc, key: feature.properties.key, ca: feature.properties.ca, captured_at: feature.properties.captured_at, pano: feature.properties.pano }; } else if (which === 'objects') { d = { loc: loc, key: feature.properties.key, value: feature.properties.value, package: feature.properties.package, detections: feature.properties.detections }; // cache image_key -> detection_key feature.properties.detections.forEach(function(detection) { var ik = detection.image_key; var dk = detection.detection_key; if (!mapillaryCache.detections[ik]) { mapillaryCache.detections[ik] = {}; } if (!mapillaryCache.detections[ik][dk]) { mapillaryCache.detections[ik][dk] = {}; } }); } return { minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d }; }); cache.rtree.load(features); if (which === 'images') { dispatch.call('loadedImages'); } else if (which === 'objects') { dispatch.call('loadedSigns'); } if (data.features.length === maxResults) { // more pages to load cache.nextPage[tile.id] = nextPage + 1; loadNextTilePage(which, currZoom, url, tile); } else { cache.nextPage[tile.id] = Infinity; // no more pages to load } }); } // extract links to pages of API results function parsePagination(links) { return links.split(',').map(function(rel) { var elements = rel.split(';'); if (elements.length === 2) { return [ /<(.+)>/.exec(elements[0])[1], /rel="(.+)"/.exec(elements[1])[1] ]; } else { return ['','']; } }).reduce(function(pagination, val) { pagination[val[1]] = val[0]; return pagination; }, {}); } // partition viewport into `psize` x `psize` regions function partitionViewport(psize, projection) { var dimensions = projection.clipExtent()[1]; psize = psize || 16; var cols = d3_range(0, dimensions[0], psize), rows = d3_range(0, dimensions[1], psize), partitions = []; rows.forEach(function(y) { cols.forEach(function(x) { var min = [x, y + psize], max = [x + psize, y]; partitions.push( geoExtent(projection.invert(min), projection.invert(max))); }); }); return partitions; } // no more than `limit` results per partition. function searchLimited(psize, limit, projection, rtree) { limit = limit || 3; var partitions = partitionViewport(psize, projection); var results; // console.time('previous'); results = _flatten(_map(partitions, function(extent) { return rtree.search(extent.bbox()) .slice(0, limit) .map(function(d) { return d.data; }); })); // console.timeEnd('previous'); // console.time('new'); // results = partitions.reduce(function(result, extent) { // var found = rtree.search(extent.bbox()) // .map(function(d) { return d.data; }) // .sort(function(a, b) { // return a.loc[1] - b.loc[1]; // // return a.key.localeCompare(b.key); // }) // .slice(0, limit); // return (found.length ? result.concat(found) : result); // }, []); // console.timeEnd('new'); return results; } export default { init: function() { if (!mapillaryCache) { this.reset(); } this.event = utilRebind(this, dispatch, 'on'); }, reset: function() { var cache = mapillaryCache; if (cache) { if (cache.images && cache.images.inflight) { _forEach(cache.images.inflight, abortRequest); } if (cache.objects && cache.objects.inflight) { _forEach(cache.objects.inflight, abortRequest); } } mapillaryCache = { images: { inflight: {}, loaded: {}, nextPage: {}, nextURL: {}, rtree: rbush() }, objects: { inflight: {}, loaded: {}, nextPage: {}, nextURL: {}, rtree: rbush() }, detections: {} }; mapillaryImage = null; mapillaryClicks = []; }, images: function(projection) { var psize = 16, limit = 3; return searchLimited(psize, limit, projection, mapillaryCache.images.rtree); }, signs: function(projection) { var psize = 32, limit = 3; return searchLimited(psize, limit, projection, mapillaryCache.objects.rtree); }, signsSupported: function() { var detected = utilDetect(); if (detected.ie) return false; if ((detected.browser.toLowerCase() === 'safari') && (parseFloat(detected.version) < 10)) return false; return true; }, signHTML: function(d) { if (!mapillarySignDefs || !mapillarySignSprite) return; var position = mapillarySignDefs[d.value]; if (!position) return '
'; var iconStyle = [ 'background-image:url(' + mapillarySignSprite + ')', 'background-repeat:no-repeat', 'height:' + position.height + 'px', 'width:' + position.width + 'px', 'background-position-x:-' + position.x + 'px', 'background-position-y:-' + position.y + 'px', ]; return '
'; }, loadImages: function(projection) { var url = apibase + 'images?'; loadTiles('images', url, projection); }, loadSigns: function(context, projection) { var url = apibase + 'objects?'; loadTiles('objects', url, projection); // load traffic sign defs if (!mapillarySignDefs) { mapillarySignSprite = context.asset('img/traffic-signs/traffic-signs.png'); mapillarySignDefs = {}; d3_json(context.asset('img/traffic-signs/traffic-signs.json'), function(err, data) { if (err) return; mapillarySignDefs = data; }); } }, loadViewer: function(context) { var that = this; var wrap = d3_select('#content').selectAll('.mapillary-wrap') .data([0]); var enter = wrap.enter() .append('div') .attr('class', 'mapillary-wrap') .classed('al', true) // 'al'=left, 'ar'=right .classed('hidden', true); enter .append('button') .attr('class', 'thumb-hide') .on('click', function () { that.hideViewer(); }) .append('div') .call(svgIcon('#icon-close')); enter .append('div') .attr('id', 'mly') .attr('class', 'mly-wrapper') .classed('active', false); // load mapillary-viewercss d3_select('head').selectAll('#mapillary-viewercss') .data([0]) .enter() .append('link') .attr('id', 'mapillary-viewercss') .attr('rel', 'stylesheet') .attr('href', context.asset(viewercss)); // load mapillary-viewerjs d3_select('head').selectAll('#mapillary-viewerjs') .data([0]) .enter() .append('script') .attr('id', 'mapillary-viewerjs') .attr('src', context.asset(viewerjs)); }, showViewer: function() { d3_select('#content') .selectAll('.mapillary-wrap') .classed('hidden', false) .selectAll('.mly-wrapper') .classed('active', true); return this; }, hideViewer: function() { d3_select('#content') .selectAll('.mapillary-wrap') .classed('hidden', true) .selectAll('.mly-wrapper') .classed('active', false); d3_selectAll('.layer-mapillary-images .viewfield-group, .layer-mapillary-signs .icon-sign') .classed('selected', false); mapillaryImage = null; return this; }, parsePagination: parsePagination, updateViewer: function(imageKey, context) { if (!imageKey) return; if (!mapillaryViewer) { this.initViewer(imageKey, context); } else { mapillaryViewer.moveToKey(imageKey); } return this; }, initViewer: function(imageKey, context) { var that = this; if (Mapillary && imageKey) { var opts = { baseImageSize: 320, component: { cover: false, keyboard: false, tag: true } }; mapillaryViewer = new Mapillary.Viewer('mly', clientId, imageKey, opts); mapillaryViewer.on('nodechanged', nodeChanged); } // nodeChanged: called after the viewer has changed images and is ready. // // There is some logic here to batch up clicks into a mapillaryClicks array // because the user might click on a lot of markers quickly and nodechanged // may be called out of order asychronously. // // Clicks are added to the array in `selectedImage` and removed here. // function nodeChanged(node) { mapillaryViewer.getComponent('tag').removeAll(); // remove previous detections var clicks = mapillaryClicks; var index = clicks.indexOf(node.key); if (index > -1) { // `nodechanged` initiated from clicking on a marker.. clicks.splice(index, 1); // If `node.key` matches the current mapillaryImage, call `selectedImage()` // one more time to update the detections and attribution.. if (node.key === mapillaryImage) { that.selectedImage(node.key, false); } } else { // `nodechanged` initiated from the Mapillary viewer controls.. var loc = node.computedLatLon ? [node.computedLatLon.lon, node.computedLatLon.lat] : [node.latLon.lon, node.latLon.lat]; context.map().centerEase(loc); that.selectedImage(node.key, false); } } }, selectedImage: function(imageKey, fromClick) { if (!arguments.length) return mapillaryImage; mapillaryImage = imageKey; if (fromClick) { mapillaryClicks.push(imageKey); } d3_selectAll('.layer-mapillary-images .viewfield-group') .classed('selected', function(d) { return d.key === imageKey; }); d3_selectAll('.layer-mapillary-signs .icon-sign') .classed('selected', function(d) { return _some(d.detections, function(detection) { return detection.image_key === imageKey; }); }); if (!imageKey) return this; function localeTimestamp(s) { if (!s) return null; var d = new Date(s); if (isNaN(d.getTime())) return null; return d.toLocaleString(undefined, { timeZone: 'UTC' }); } var selected = d3_selectAll('.layer-mapillary-images .viewfield-group.selected'); if (selected.empty()) return this; var datum = selected.datum(); var timestamp = localeTimestamp(datum.captured_at); var attribution = d3_select('.mapillary-js-dom .Attribution'); var capturedAt = attribution.selectAll('.captured-at'); if (capturedAt.empty()) { attribution .append('span') .text('|'); capturedAt = attribution .append('span') .attr('class', 'captured-at'); } capturedAt .text(timestamp); this.updateDetections(); return this; }, updateDetections: function() { if (!mapillaryViewer) return; var detections = mapillaryCache.detections[mapillaryImage]; _forEach(detections, function(data, k) { if (_isEmpty(data)) { loadDetection(k); } else { var tag = makeTag(data); if (tag) { var tagComponent = mapillaryViewer.getComponent('tag'); tagComponent.add([tag]); } } }); function loadDetection(detectionKey) { var url = apibase + 'detections/'+ detectionKey + '?' + utilQsString({ client_id: clientId, }); d3_request(url) .mimeType('application/json') .response(function(xhr) { return JSON.parse(xhr.responseText); }) .get(function(err, data) { if (!data || !data.properties) return; var ik = data.properties.image_key; mapillaryCache.detections[ik][detectionKey] = data; if (mapillaryImage === ik) { var tag = makeTag(data); if (tag) { var tagComponent = mapillaryViewer.getComponent('tag'); tagComponent.add([tag]); } } }); } function makeTag(data) { var valueParts = data.properties.value.split('--'); if (valueParts.length !== 3) return; var text = valueParts[1].replace(/-/g, ' '); var tag; // Currently only two shapes if (data.properties.shape.type === 'Polygon') { var polygonGeometry = new Mapillary .TagComponent .PolygonGeometry(data.properties.shape.coordinates[0]); tag = new Mapillary.TagComponent.OutlineTag( data.properties.key, polygonGeometry, { text: text, textColor: 0xffff00, lineColor: 0xffff00, lineWidth: 2, fillColor: 0xffff00, fillOpacity: 0.3, } ); } else if (data.properties.shape.type === 'Point') { var pointGeometry = new Mapillary .TagComponent .PointGeometry(data.properties.shape.coordinates[0]); tag = new Mapillary.TagComponent.SpotTag( data.properties.key, pointGeometry, { text: text, color: 0xffff00, textColor: 0xffff00 } ); } return tag; } }, cache: function(_) { if (!arguments.length) return mapillaryCache; mapillaryCache = _; return this; }, signDefs: function(_) { if (!arguments.length) return mapillarySignDefs; mapillarySignDefs = _; return this; } };