diff --git a/modules/services/imagery_offset.js b/modules/services/imagery_offset.js new file mode 100644 index 0000000000..9c27ec5f03 --- /dev/null +++ b/modules/services/imagery_offset.js @@ -0,0 +1,168 @@ +import * as d3 from 'd3'; +import _ from 'lodash'; +import rbush from 'rbush'; +import { geoExtent, geoSphericalDistance } from '../geo/index'; +import { utilQsString } from '../util/index'; + +var apibase = 'http://offsets.textual.ru/get?', + inflight = {}, + offsetCache; + +export default { + init: function() { + inflight = {}; + offsetCache = rbush(); + window.offsetCache = offsetCache; + }, + + reset: function() { + _.forEach(inflight, function(req) { + req.abort(); + }); + inflight = {}; + offsetCache = rbush(); + }, + getImageryID: function(url) { + if (url == null) return; + + url = url.toLowerCase(); + if (url.indexOf('tiles.virtualearth.net') > -1) return 'bing'; + + if (url.match(/.+tiles\.mapbox\.com\/v[3-9]\/openstreetmap\.map.*/)) + return 'mapbox'; + + // Remove protocol + var i = url.indexOf('://'); + url = url.substring(i + 3); + var query = ''; + + // Split URL into address and query string + var questionMarkIndex = url.indexOf('?'); + if (questionMarkIndex > 0) { + query = url.slice(questionMarkIndex); + url = url.slice(0, questionMarkIndex); + } + + var qparams = {}; + var qparamsStr = query.length > 1 ? query.slice(1).split('&') : ''; + + qparamsStr.forEach(function(param) { + var kv = param.split('='); + kv[0] = kv[0].toLowerCase(); + + // TMS: skip parameters with variable values and Mapbox's access token + if ( + (kv.length > 1 && + kv[1].indexOf('{') >= 0 && + kv[1].indexOf('}') > 0) || + kv[0] === 'access_token' + ) { + return; + } + qparams[kv[0].toLowerCase()] = kv.length > 1 ? kv[1] : null; + }); + + var sb = ''; + Object.keys(qparams).forEach(function(qk) { + if (sb.length > 0) sb += '&'; + else if (query.length > 0) sb += '?'; + sb += qk + '=' + qparams[qk]; + }); + + // TMS: remove /{zoom} and /{y}.png parts + url = url.replace(/\/\{[^}]+\}(?:\.\w+)?/g, ''); + + // TMS: remove variable parts + url = url.replace(/\{[^}]+\}/g, ''); + + while (url.indexOf('..') > -1) { + url = url.replace('..', '.'); + } + + if (url.startsWith('.')) url = url.substring(1); + + return url + query; + }, + match: function(location, imageryId, data) { + // TOFIX: need to figure out the closest distance + // to start with, ideally it should be distance of + // center screen to nearest edge. + var closestDistance = Infinity; + var matchedImagery; + + data + .filter(function(d) { + return d.data.imagery === imageryId; + }) + .forEach(function(d) { + var imagery = d.data; + var dist = geoSphericalDistance( + [parseFloat(imagery.lon), parseFloat(imagery.lat)], + location + ); + if (dist < closestDistance) { + closestDistance = dist; + matchedImagery = imagery; + return d.data; + } + }); + return matchedImagery; + }, + search: function(location, url, callback) { + var cached = offsetCache.search({ + minX: location[0], + minY: location[1], + maxX: location[0], + maxY: location[1] + }); + + var imageryId = this.getImageryID(url); + + if (cached.length > 0) { + return callback(null, this.match(location, imageryId, cached)); + } + + var params = { + radius: 10, // default is 10kms + format: 'json', + lat: location[1], + lon: location[0] + }; + + var databaseUrl = apibase + utilQsString(params); + + if (inflight[databaseUrl]) return; + var that = this; + inflight[databaseUrl] = d3.json(databaseUrl, function(err, result) { + delete inflight[databaseUrl]; + + if (err) { + return callback(err); + } else if (result && result.error) { + return callback(result.error); + } + + if (result.length < 2) { + return callback('No imagery offset found.'); + } + // the first entry is always a timestamp + // which can be discarded. + result = result.slice(1); + result + .filter(function(imagery) { + return imagery.type === 'offset'; + }) + .forEach(function(imagery) { + var extent = geoExtent([ + parseFloat(imagery.lon), + parseFloat(imagery.lat) + ]).padByMeters(9 * 1000); // need to figure out how much to pad + + offsetCache.insert( + _.assign(extent.bbox(), { data: imagery }) + ); + }); + callback(null, that.match(location, imageryId, cached)); + }); + } +}; diff --git a/modules/services/index.js b/modules/services/index.js index ea8f600c74..8aede9dcbd 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -4,12 +4,15 @@ import serviceOsm from './osm'; import serviceTaginfo from './taginfo'; import serviceWikidata from './wikidata'; import serviceWikipedia from './wikipedia'; +import serviceImageryOffset from './imagery_offset'; +window.offset = serviceImageryOffset; export var services = { mapillary: serviceMapillary, geocoder: serviceNominatim, osm: serviceOsm, taginfo: serviceTaginfo, wikidata: serviceWikidata, - wikipedia: serviceWikipedia + wikipedia: serviceWikipedia, + imageryOffset: serviceImageryOffset }; diff --git a/modules/ui/panels/background.js b/modules/ui/panels/background.js index 44725af1ba..5ceac800bb 100644 --- a/modules/ui/panels/background.js +++ b/modules/ui/panels/background.js @@ -1,7 +1,10 @@ import * as d3 from 'd3'; import _ from 'lodash'; +import {services} from '../../services' import { t } from '../../util/locale'; +var searchOffset = services.imageryOffset; +searchOffset.init(); export function uiPanelBackground(context) { var background = context.background(); @@ -9,7 +12,6 @@ export function uiPanelBackground(context) { var currZoom = ''; var currVintage = ''; - function redraw(selection) { if (currSource !== background.baseLayerSource().name()) { currSource = background.baseLayerSource().name(); @@ -44,7 +46,6 @@ export function uiPanelBackground(context) { if (!currVintage) { debouncedGetVintage(selection); } - var toggle = context.getDebug('tile') ? 'hide_tiles' : 'show_tiles'; selection @@ -61,6 +62,8 @@ export function uiPanelBackground(context) { var debouncedGetVintage = _.debounce(getVintage, 250); + var debouncedGetOffset = _.debounce(getOffset, 500); + function getVintage(selection) { var tile = d3.select('.layer-background img.tile-center'); // tile near viewport center if (tile.empty()) return; @@ -81,6 +84,25 @@ export function uiPanelBackground(context) { }); } + function getOffset() { + var tile = d3.select('.layer-background img.tile-center'); // tile near viewport center + if (tile.empty()) return; + + var d = tile.datum(); + var url = d[3]; + var center = context.map().center(); + searchOffset.search(center, url, function(error, imagery) { + if (error || !imagery) return; + + context + .background() + .offset([ + (imagery.lon - imagery.imlon) * -1, + (imagery.lat - imagery.imlat) * -1 + ]); + }); + } + var panel = function(selection) { selection.call(redraw); @@ -91,6 +113,7 @@ export function uiPanelBackground(context) { }) .on('move.info-background', function() { selection.call(debouncedGetVintage); + selection.call(debouncedGetOffset); }); }; diff --git a/test/index.html b/test/index.html index ad42a95b70..d53d9a2090 100644 --- a/test/index.html +++ b/test/index.html @@ -100,6 +100,7 @@ + diff --git a/test/spec/services/imagery_offset.js b/test/spec/services/imagery_offset.js new file mode 100644 index 0000000000..864b82046d --- /dev/null +++ b/test/spec/services/imagery_offset.js @@ -0,0 +1,130 @@ +describe.only('iD.services.imageryOffset', function() { + var server, imageryOffset; + + beforeEach(function() { + server = sinon.fakeServer.create(); + imageryOffset = iD.services.imageryOffset; + imageryOffset.reset(); + }); + + afterEach(function() { + server.restore(); + }); + + function query(url) { + return iD.utilStringQs(url.substring(url.indexOf('?') + 1)); + } + + describe('#search', function() { + var serverResponse = + '[{"type":"meta","timestamp":"2017-07-24T09:19:35+0300"},{"type":"offset","id":"15026","lat":"53.8119907","lon":"-1.6134077","author":"Arthtoach","description":"Victoria Park Avenue / Lancastre Grove Junction and Buildings","date":"2017-05-09","min-zoom":"0","max-zoom":"30","imagery":"a.tiles.mapbox.com/v4/digitalglobe.n6ngnadl","imlat":"53.8120239","imlon":"-1.6133688"}]'; + var emptyResponse = + '[{"type":"meta","timestamp":"2017-07-24T09:19:35+0300"}]'; + it('should cache results', function() { + var callback = sinon.spy(); + imageryOffset.search([-1.619, 53.8129], callback); + + server.respondWith( + 'GET', + new RegExp('http://offsets.textual.ru/get'), + [200, { 'Content-Type': 'application/json' }, serverResponse] + ); + server.respond(); + + expect(query(server.requests[0].url)).to.eql({ + radius: '10', + format: 'json', + lat: '53.8129', + lon: '-1.619' + }); + expect(callback).to.have.been.calledWithExactly( + null, + JSON.parse(serverResponse).slice(1) + ); + + server.restore(); + server = sinon.fakeServer.create(); + + callback = sinon.spy(); + imageryOffset.search([-1.609, 53.8149], callback); + + server.respondWith( + 'GET', + new RegExp('http://offsets.textual.ru/get'), + [200, { 'Content-Type': 'application/json' }, emptyResponse] + ); + server.respond(); + + expect(callback).to.have.been.calledWithExactly( + null, + JSON.parse(serverResponse).slice(1) + ); + }); + + it('should make network request if cache miss', function() { + var callback = sinon.spy(); + imageryOffset.search([-1.619, 53.8129], callback); + + server.respondWith( + 'GET', + new RegExp('http://offsets.textual.ru/get'), + [200, { 'Content-Type': 'application/json' }, serverResponse] + ); + server.respond(); + + expect(query(server.requests[0].url)).to.eql({ + radius: '10', + format: 'json', + lat: '53.8129', + lon: '-1.619' + }); + expect(callback).to.have.been.calledWithExactly( + null, + JSON.parse(serverResponse).slice(1) + ); + + server.restore(); + server = sinon.fakeServer.create(); + + callback = sinon.spy(); + imageryOffset.search([-2.237, 57.1152], callback); + + var newReponse = + '[{"type":"meta","timestamp":"2017-07-24T10:45:56+0300"},{"type":"offset","id":"12582","lat":"57.1470244","lon":"-2.097151","author":"neiljp","description":"Aberdeen","date":"2016-03-19","min-zoom":"0","max-zoom":"30","imagery":"geo.nls.uk/maps/towns/aberdeen","imlat":"57.147044","imlon":"-2.0971537"}]'; + + server.respondWith( + 'GET', + new RegExp('http://offsets.textual.ru/get'), + [200, { 'Content-Type': 'application/json' }, newReponse] + ); + server.respond(); + + expect(callback).to.have.been.calledWithExactly( + null, + JSON.parse(newReponse).slice(1) + ); + }); + + it('Error if empty responce', function() { + var callback = sinon.spy(); + imageryOffset.search([-1.619, 53.8129], callback); + + server.respondWith( + 'GET', + new RegExp('http://offsets.textual.ru/get'), + [200, { 'Content-Type': 'application/json' }, emptyResponse] + ); + server.respond(); + + expect(query(server.requests[0].url)).to.eql({ + radius: '10', + format: 'json', + lat: '53.8129', + lon: '-1.619' + }); + expect(callback).to.have.been.calledWithExactly( + 'No imagery offset found.' + ); + }); + }); +});