diff --git a/CHANGELOG.md b/CHANGELOG.md index 257447b55..4ebd9da15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ _Breaking developer changes, which may affect downstream projects or sites that * Add support for coordinates in ` [ ]` format to search bar ([#10066], thanks [@NaVis0mple]) #### :scissors: Operations #### :camera: Street-Level +* Fetch Microsoft Bing Streetlevel imagery layer via the Bing Maps API (from the previously used undocumented internal API endpoints), fixing reliability issues with the service's integration ([#10074]) #### :white_check_mark: Validation #### :bug: Bugfixes * Show turn restriction editor also when there is only one possible "to" way, as there might exist restrictions with that way as _via_ ([#9983]) @@ -70,6 +71,7 @@ _Breaking developer changes, which may affect downstream projects or sites that [#9992]: https://github.com/openstreetmap/iD/issues/9992 [#10062]: https://github.com/openstreetmap/iD/pull/10062 [#10066]: https://github.com/openstreetmap/iD/pull/10066 +[#10074]: https://github.com/openstreetmap/iD/issues/10074 [id-tagging-schema#1076]: https://github.com/openstreetmap/id-tagging-schema/pull/1076 [@ramith-kulal]: https://github.com/ramith-kulal [@mangerlahn]: https://github.com/mangerlahn diff --git a/modules/geo/extent.js b/modules/geo/extent.js index c8d65d4d5..37105ddd6 100644 --- a/modules/geo/extent.js +++ b/modules/geo/extent.js @@ -132,6 +132,16 @@ Object.assign(geoExtent.prototype, { toParam: function() { return this.rectangle().join(','); + }, + + split: function() { + const center = this.center(); + return [ + geoExtent(this[0], center), + geoExtent([center[0], this[0][1]], [this[1][0], center[1]]), + geoExtent(center, this[1]), + geoExtent([this[0][0], center[1]], [center[0], this[1][1]]) + ]; } }); diff --git a/modules/services/streetside.js b/modules/services/streetside.js index b176e672e..1a4cb30ab 100644 --- a/modules/services/streetside.js +++ b/modules/services/streetside.js @@ -7,22 +7,20 @@ import { import RBush from 'rbush'; import { t, localizer } from '../core/localizer'; -import { jsonpRequest } from '../util/jsonp_request'; import { geoExtent, geoMetersToLat, geoMetersToLon, geoPointInPolygon, geoRotate, geoScaleToZoom, geoVecLength } from '../geo'; -import { utilArrayUnion, utilQsString, utilRebind, utilStringQs, utilTiler, utilUniqueDomId } from '../util'; +import { utilAesDecrypt, utilArrayUnion, utilQsString, utilRebind, utilStringQs, utilTiler, utilUniqueDomId } from '../util'; -const bubbleApi = 'https://dev.virtualearth.net/mapcontrol/HumanScaleServices/GetBubbles.ashx?'; -const streetsideImagesApi = 'https://t.ssl.ak.tiles.virtualearth.net/tiles/'; -const bubbleAppKey = 'AuftgJsO0Xs8Ts4M1xZUQJQXJNsvmh3IV8DkNieCiy3tCwCUMq76-WpkrBtNAuEm'; +const streetsideApi = 'http://dev.virtualearth.net/REST/v1/Imagery/MetaData/Streetside?mapArea={bbox}&key={key}&count={count}'; +const maxResults = 500; +const bubbleAppKey = utilAesDecrypt('5c875730b09c6b422433e807e1ff060b6536c791dbfffcffc4c6b18a1bdba1f14593d151adb50e19e1be1ab19aef813bf135d0f103475e5c724dec94389e45d0'); const pannellumViewerCSS = 'pannellum/pannellum.css'; const pannellumViewerJS = 'pannellum/pannellum.js'; -const maxResults = 2000; const tileZoom = 16.5; const tiler = utilTiler().zoomExtent([tileZoom, tileZoom]).skipNullIsland(true); const dispatch = d3_dispatch('loadedImages', 'viewerChanged'); @@ -98,40 +96,39 @@ function loadNextTilePage(which, url, tile) { const id = tile.id + ',' + String(nextPage); if (cache.loaded[id] || cache.inflight[id]) return; - cache.inflight[id] = getBubbles(url, tile, (bubbles) => { + cache.inflight[id] = getBubbles(url, tile, response => { cache.loaded[id] = true; delete cache.inflight[id]; - if (!bubbles) return; + if (!response) return; - // [].shift() removes the first element, some statistics info, not a bubble point - bubbles.shift(); + if (response.resourceSets[0].resources.length === maxResults) { + // there are more bubbles than the response can fit: re-fetch using tile split into 4 + const split = tile.extent.split(); + loadNextTilePage(which, url, { id: tile.id + ',a', extent: split[0] }); + loadNextTilePage(which, url, { id: tile.id + ',b', extent: split[1] }); + loadNextTilePage(which, url, { id: tile.id + ',c', extent: split[2] }); + loadNextTilePage(which, url, { id: tile.id + ',d', extent: split[3] }); + } - const features = bubbles.map(bubble => { - if (cache.points[bubble.id]) return null; // skip duplicates + const features = response.resourceSets[0].resources.map(bubble => { + const bubbleId = bubble.imageUrl; + if (cache.points[bubbleId]) return null; // skip duplicates - const loc = [bubble.lo, bubble.la]; + const loc = [bubble.lon, bubble.lat]; const d = { loc: loc, - key: bubble.id, + key: bubbleId, + imageUrl: bubble.imageUrl.replace('{subdomain}', + bubble.imageUrlSubdomains[0] + ), ca: bubble.he, - captured_at: bubble.cd, + captured_at: bubble.vintageEnd, captured_by: 'microsoft', - // nbn: bubble.nbn, - // pbn: bubble.pbn, - // ad: bubble.ad, - // rn: bubble.rn, - pr: bubble.pr, // previous - ne: bubble.ne, // next pano: true, sequenceKey: null }; - cache.points[bubble.id] = d; - - // a sequence starts here - if (bubble.pr === undefined) { - cache.leaders.push(bubble.id); - } + cache.points[bubbleId] = d; return { minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d @@ -141,8 +138,6 @@ function loadNextTilePage(which, url, tile) { cache.rtree.load(features); - connectSequences(); - if (which === 'bubbles') { dispatch.call('loadedImages'); } @@ -150,83 +145,37 @@ function loadNextTilePage(which, url, tile) { } -// call this sometimes to connect the bubbles into sequences -function connectSequences() { - let cache = _ssCache.bubbles; - let keepLeaders = []; - - for (let i = 0; i < cache.leaders.length; i++) { - let bubble = cache.points[cache.leaders[i]]; - let seen = {}; - - // try to make a sequence.. use the key of the leader bubble. - let sequence = { key: bubble.key, bubbles: [] }; - let complete = false; - - do { - sequence.bubbles.push(bubble); - seen[bubble.key] = true; - - if (bubble.ne === undefined) { - complete = true; - } else { - bubble = cache.points[bubble.ne]; // advance to next - } - } while (bubble && !seen[bubble.key] && !complete); - - - if (complete) { - _ssCache.sequences[sequence.key] = sequence; - - // assign bubbles to the sequence - for (let j = 0; j < sequence.bubbles.length; j++) { - sequence.bubbles[j].sequenceKey = sequence.key; - } - - // create a GeoJSON LineString - sequence.geojson = { - type: 'LineString', - properties: { - captured_at: sequence.bubbles[0] ? sequence.bubbles[0].captured_at : null, - captured_by: sequence.bubbles[0] ? sequence.bubbles[0].captured_by : null, - key: sequence.key - }, - coordinates: sequence.bubbles.map(d => d.loc) - }; - - } else { - keepLeaders.push(cache.leaders[i]); - } - } - - // couldn't complete these, save for later - cache.leaders = keepLeaders; -} - - /** * getBubbles() handles the request to the server for a tile extent of 'bubbles' (streetside image locations). */ function getBubbles(url, tile, callback) { let rect = tile.extent.rectangle(); - let urlForRequest = url + utilQsString({ - n: rect[3], - s: rect[1], - e: rect[2], - w: rect[0], - c: maxResults, - appkey: bubbleAppKey, - jsCallback: '{callback}' - }); + let urlForRequest = url + .replace('{key}', bubbleAppKey) + .replace('{bbox}', [rect[1], rect[0], rect[3], rect[2]].join(',')) + .replace('{count}', maxResults); - return jsonpRequest(urlForRequest, (data) => { - if (!data || data.error) { - callback(null); - } else { - callback(data); - } - }); -} + const controller = new AbortController(); + fetch(urlForRequest, { signal: controller.signal }) + .then(function(response) { + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + return response.json(); + }).then(function(result) { + if (!result) { + callback(null); + } + return callback(result || []); + }).catch(function(err) { + if (err.name === 'AbortError') { + // ignore aborted requests, e.g. from duplicate requests while zooming/panning the map + } else { + throw new Error(err); + } + }); + return controller; + } // partition viewport into higher zoom tiles @@ -418,7 +367,7 @@ export default { } _ssCache = { - bubbles: { inflight: {}, loaded: {}, nextPage: {}, rtree: new RBush(), points: {}, leaders: [] }, + bubbles: { inflight: {}, loaded: {}, nextPage: {}, rtree: new RBush(), points: {} }, sequences: {} }; }, @@ -466,7 +415,7 @@ export default { // by default: request 2 nearby tiles so we can connect sequences. if (margin === undefined) margin = 2; - loadTiles('bubbles', bubbleApi, projection, margin); + loadTiles('bubbles', streetsideApi, projection, margin); }, @@ -845,15 +794,6 @@ export default { encodeURIComponent(d.key) + '&focus=photo&lat=' + d.loc[1] + '&lng=' + d.loc[0] + '&z=17') .call(t.append('streetside.report')); - - let bubbleIdQuadKey = d.key.toString(4); - const paddingNeeded = 16 - bubbleIdQuadKey.length; - for (let i = 0; i < paddingNeeded; i++) { - bubbleIdQuadKey = '0' + bubbleIdQuadKey; - } - const imgUrlPrefix = streetsideImagesApi + 'hs' + bubbleIdQuadKey; - const imgUrlSuffix = '.jpg?g=13515&n=z'; - // Cubemap face code order matters here: front=01, right=02, back=03, left=10, up=11, down=12 const faceKeys = ['01','02','03','10','11','12']; @@ -864,7 +804,9 @@ export default { const xy = qkToXY(quadKey); return { face: faceKey, - url: imgUrlPrefix + faceKey + quadKey + imgUrlSuffix, + url: d.imageUrl + .replace('{faceId}', faceKey) + .replace('{tileId}', quadKey), x: xy[0], y: xy[1] }; diff --git a/modules/util/jsonp_request.js b/modules/util/jsonp_request.js deleted file mode 100644 index ba5d32c27..000000000 --- a/modules/util/jsonp_request.js +++ /dev/null @@ -1,61 +0,0 @@ -import { select as d3_select } from 'd3-selection'; - -var jsonpCache = {}; -window.jsonpCache = jsonpCache; - -export function jsonpRequest(url, callback) { - var request = { - abort: function() {} - }; - - if (window.JSONP_FIX) { - if (window.JSONP_DELAY === 0) { - callback(window.JSONP_FIX); - } else { - var t = window.setTimeout(function() { - callback(window.JSONP_FIX); - }, window.JSONP_DELAY || 0); - - request.abort = function() { window.clearTimeout(t); }; - } - - return request; - } - - function rand() { - var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - var c = ''; - var i = -1; - while (++i < 15) c += chars.charAt(Math.floor(Math.random() * 52)); - return c; - } - - function create(url) { - var e = url.match(/callback=(\w+)/); - var c = e ? e[1] : rand(); - - jsonpCache[c] = function(data) { - if (jsonpCache[c]) { - callback(data); - } - finalize(); - }; - - function finalize() { - delete jsonpCache[c]; - script.remove(); - } - - request.abort = finalize; - return 'jsonpCache.' + c; - } - - var cb = create(url); - - var script = d3_select('head') - .append('script') - .attr('type', 'text/javascript') - .attr('src', url.replace(/(\{|%7B)callback(\}|%7D)/, cb)); - - return request; -} diff --git a/test/spec/geo/extent.js b/test/spec/geo/extent.js index 18fe34c1e..aac385f09 100644 --- a/test/spec/geo/extent.js +++ b/test/spec/geo/extent.js @@ -247,4 +247,15 @@ describe('iD.geoExtent', function () { }); + describe('#split', function () { + it('splits into four parts', function () { + var splits = iD.geoExtent([0, 10], [5, 20]).split(); + expect(splits).to.have.length(4); + expect(splits[0]).to.eql([[0, 10], [2.5, 15]]); + expect(splits[1]).to.eql([[2.5, 10], [5, 15]]); + expect(splits[2]).to.eql([[2.5, 15], [5, 20]]); + expect(splits[3]).to.eql([[0, 15], [2.5, 20]]); + }); + }); + }); diff --git a/test/spec/services/streetside.js b/test/spec/services/streetside.js index 067211212..3caea8c71 100644 --- a/test/spec/services/streetside.js +++ b/test/spec/services/streetside.js @@ -58,23 +58,16 @@ describe('iD.serviceStreetside', function() { var spy = sinon.spy(); streetside.on('loadedImages', spy); - window.JSONP_DELAY = 0; - window.JSONP_FIX = [{ - elapsed: 0.001 - }, { - id: 1, la: 0, lo: 10.001, al: 0, ro: 0, pi: 0, he: 0, bl: '', - cd: '1/1/2018 12:00:00 PM', ml: 3, nbn: [], pbn: [], rn: [], - pr: undefined, ne: 2 - }, { - id: 2, la: 0, lo: 10.002, al: 0, ro: 0, pi: 0, he: 0, bl: '', - cd: '1/1/2018 12:00:01 PM', ml: 3, nbn: [], pbn: [], rn: [], - pr: 1, ne: 3 - }, { - id: 3, la: 0, lo: 10.003, al: 0, ro: 0, pi: 0, he: 0, bl: '', - cd: '1/1/2018 12:00:02 PM', ml: 3, nbn: [], pbn: [], rn: [], - pr: 2, ne: undefined - } - ]; + var mockData = { + resourceSets: [{ + resources: [] + }] + }; + + fetchMock.mock(/MetaData\/Streetside/, { + body: JSON.stringify(mockData), + status: 200 + }); streetside.loadBubbles(context.projection, 0); // 0 = don't fetch margin tiles @@ -93,23 +86,16 @@ describe('iD.serviceStreetside', function() { var spy = sinon.spy(); streetside.on('loadedImages', spy); - window.JSONP_DELAY = 0; - window.JSONP_FIX = [{ - elapsed: 0.001 - }, { - id: 1, la: 0, lo: 0, al: 0, ro: 0, pi: 0, he: 0, bl: '', - cd: '1/1/2018 12:00:00 PM', ml: 3, nbn: [], pbn: [], rn: [], - pr: undefined, ne: 2 - }, { - id: 2, la: 0, lo: 0, al: 0, ro: 0, pi: 0, he: 0, bl: '', - cd: '1/1/2018 12:00:01 PM', ml: 3, nbn: [], pbn: [], rn: [], - pr: 1, ne: 3 - }, { - id: 3, la: 0, lo: 0, al: 0, ro: 0, pi: 0, he: 0, bl: '', - cd: '1/1/2018 12:00:02 PM', ml: 3, nbn: [], pbn: [], rn: [], - pr: 2, ne: undefined - } - ]; + var mockData = { + resourceSets: [{ + resources: [{}] + }] + }; + + fetchMock.mock(/MetaData\/Streetside/, { + body: JSON.stringify(mockData), + status: 200 + }); streetside.loadBubbles(context.projection, 0); // 0 = don't fetch margin tiles