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.'
+ );
+ });
+ });
+});