Files
iD/modules/services/osm.js
2018-08-16 10:16:33 -04:00

1119 lines
33 KiB
JavaScript

import _chunk from 'lodash-es/chunk';
import _cloneDeep from 'lodash-es/cloneDeep';
import _extend from 'lodash-es/extend';
import _forEach from 'lodash-es/forEach';
import _find from 'lodash-es/find';
import _groupBy from 'lodash-es/groupBy';
import _isEmpty from 'lodash-es/isEmpty';
import _map from 'lodash-es/map';
import _throttle from 'lodash-es/throttle';
import _uniq from 'lodash-es/uniq';
import rbush from 'rbush';
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { xml as d3_xml } from 'd3-request';
import osmAuth from 'osm-auth';
import { JXON } from '../util/jxon';
import { geoExtent, geoVecAdd } from '../geo';
import {
osmEntity,
osmNode,
osmNote,
osmRelation,
osmWay
} from '../osm';
import {
utilRebind,
utilIdleWorker,
utilTiler,
utilQsString
} from '../util';
var tiler = utilTiler();
var dispatch = d3_dispatch('authLoading', 'authDone', 'change', 'loading', 'loaded', 'loadedNotes');
var urlroot = 'https://www.openstreetmap.org';
var oauth = osmAuth({
url: urlroot,
oauth_consumer_key: '5A043yRSEugj4DJ5TljuapfnrflWDte8jTOcWLlT',
oauth_secret: 'aB3jKq1TRsCOUrfOIZ6oQMEDmv2ptV76PA54NGLL',
loading: authLoading,
done: authDone
});
var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*'];
var _tileCache = { loaded: {}, inflight: {}, seen: {} };
var _noteCache = { loaded: {}, inflight: {}, inflightPost: {}, note: {}, rtree: rbush() };
var _userCache = { toLoad: {}, user: {} };
var _changeset = {};
var _connectionID = 1;
var _tileZoom = 16;
var _noteZoom = 12;
var _rateLimitError;
var _userChangesets;
var _userDetails;
var _off;
function authLoading() {
dispatch.call('authLoading');
}
function authDone() {
dispatch.call('authDone');
}
function abortRequest(i) {
if (i) {
i.abort();
}
}
function abortUnwantedRequests(cache, tiles) {
_forEach(cache.inflight, function(v, k) {
var wanted = _find(tiles, function(tile) { return k === tile.id; });
if (!wanted) {
abortRequest(v);
delete cache.inflight[k];
}
});
}
function getLoc(attrs) {
var lon = attrs.lon && attrs.lon.value;
var lat = attrs.lat && attrs.lat.value;
return [parseFloat(lon), parseFloat(lat)];
}
function getNodes(obj) {
var elems = obj.getElementsByTagName('nd');
var nodes = new Array(elems.length);
for (var i = 0, l = elems.length; i < l; i++) {
nodes[i] = 'n' + elems[i].attributes.ref.value;
}
return nodes;
}
function getTags(obj) {
var elems = obj.getElementsByTagName('tag');
var tags = {};
for (var i = 0, l = elems.length; i < l; i++) {
var attrs = elems[i].attributes;
tags[attrs.k.value] = attrs.v.value;
}
return tags;
}
function getMembers(obj) {
var elems = obj.getElementsByTagName('member');
var members = new Array(elems.length);
for (var i = 0, l = elems.length; i < l; i++) {
var attrs = elems[i].attributes;
members[i] = {
id: attrs.type.value[0] + attrs.ref.value,
type: attrs.type.value,
role: attrs.role.value
};
}
return members;
}
function getVisible(attrs) {
return (!attrs.visible || attrs.visible.value !== 'false');
}
function parseComments(comments) {
var parsedComments = [];
// for each comment
for (var i = 0; i < comments.length; i++) {
var comment = comments[i];
if (comment.nodeName === 'comment') {
var childNodes = comment.childNodes;
var parsedComment = {};
for (var j = 0; j < childNodes.length; j++) {
var node = childNodes[j];
var nodeName = node.nodeName;
if (nodeName === '#text') continue;
parsedComment[nodeName] = node.textContent;
if (nodeName === 'uid') {
var uid = node.textContent;
if (uid && !_userCache.user[uid]) {
_userCache.toLoad[uid] = true;
}
}
}
if (parsedComment) {
parsedComments.push(parsedComment);
}
}
}
return parsedComments;
}
function encodeNoteRtree(note) {
return {
minX: note.loc[0],
minY: note.loc[1],
maxX: note.loc[0],
maxY: note.loc[1],
data: note
};
}
var parsers = {
node: function nodeData(obj, uid) {
var attrs = obj.attributes;
return new osmNode({
id: uid,
visible: getVisible(attrs),
version: attrs.version.value,
changeset: attrs.changeset && attrs.changeset.value,
timestamp: attrs.timestamp && attrs.timestamp.value,
user: attrs.user && attrs.user.value,
uid: attrs.uid && attrs.uid.value,
loc: getLoc(attrs),
tags: getTags(obj)
});
},
way: function wayData(obj, uid) {
var attrs = obj.attributes;
return new osmWay({
id: uid,
visible: getVisible(attrs),
version: attrs.version.value,
changeset: attrs.changeset && attrs.changeset.value,
timestamp: attrs.timestamp && attrs.timestamp.value,
user: attrs.user && attrs.user.value,
uid: attrs.uid && attrs.uid.value,
tags: getTags(obj),
nodes: getNodes(obj),
});
},
relation: function relationData(obj, uid) {
var attrs = obj.attributes;
return new osmRelation({
id: uid,
visible: getVisible(attrs),
version: attrs.version.value,
changeset: attrs.changeset && attrs.changeset.value,
timestamp: attrs.timestamp && attrs.timestamp.value,
user: attrs.user && attrs.user.value,
uid: attrs.uid && attrs.uid.value,
tags: getTags(obj),
members: getMembers(obj)
});
},
note: function parseNote(obj, uid) {
var attrs = obj.attributes;
var childNodes = obj.childNodes;
var props = {};
props.id = uid;
props.loc = getLoc(attrs);
// if notes are coincident, move them apart slightly
var coincident = false;
var epsilon = 0.00001;
do {
if (coincident) {
props.loc = geoVecAdd(props.loc, [epsilon, epsilon]);
}
var bbox = geoExtent(props.loc).bbox();
coincident = _noteCache.rtree.search(bbox).length;
} while (coincident);
// parse note contents
for (var i = 0; i < childNodes.length; i++) {
var node = childNodes[i];
var nodeName = node.nodeName;
if (nodeName === '#text') continue;
// if the element is comments, parse the comments
if (nodeName === 'comments') {
props[nodeName] = parseComments(node.childNodes);
} else {
props[nodeName] = node.textContent;
}
}
var note = new osmNote(props);
var item = encodeNoteRtree(note);
_noteCache.note[note.id] = note;
_noteCache.rtree.insert(item);
return note;
},
user: function parseUser(obj, uid) {
var attrs = obj.attributes;
var user = {
id: uid,
display_name: attrs.display_name && attrs.display_name.value,
account_created: attrs.account_created && attrs.account_created.value,
changesets_count: 0
};
var img = obj.getElementsByTagName('img');
if (img && img[0] && img[0].getAttribute('href')) {
user.image_url = img[0].getAttribute('href');
}
var changesets = obj.getElementsByTagName('changesets');
if (changesets && changesets[0] && changesets[0].getAttribute('count')) {
user.changesets_count = changesets[0].getAttribute('count');
}
_userCache.user[uid] = user;
delete _userCache.toLoad[uid];
return user;
}
};
function parseXML(xml, callback, options) {
options = _extend({ skipSeen: true }, options);
if (!xml || !xml.childNodes) {
return callback({ message: 'No XML', status: -1 });
}
var root = xml.childNodes[0];
var children = root.childNodes;
utilIdleWorker(children, parseChild, done);
function done(results) {
callback(null, results);
}
function parseChild(child) {
var parser = parsers[child.nodeName];
if (!parser) return null;
var uid;
if (child.nodeName === 'user') {
uid = child.attributes.id.value;
if (options.skipSeen && _userCache.user[uid]) {
delete _userCache.toLoad[uid];
return null;
}
} else if (child.nodeName === 'note') {
uid = child.getElementsByTagName('id')[0].textContent;
} else {
uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value);
if (options.skipSeen) {
if (_tileCache.seen[uid]) return null; // avoid reparsing a "seen" entity
_tileCache.seen[uid] = true;
}
}
return parser(child, uid);
}
}
// replace or remove note from rtree
function updateRtree(item, replace) {
_noteCache.rtree.remove(item, function isEql(a, b) { return a.data.id === b.data.id; });
if (replace) {
_noteCache.rtree.insert(item);
}
}
function wrapcb(thisArg, callback, cid) {
return function(err, result) {
if (err) {
// 400 Bad Request, 401 Unauthorized, 403 Forbidden..
if (err.status === 400 || err.status === 401 || err.status === 403) {
thisArg.logout();
}
return callback.call(thisArg, err);
} else if (thisArg.getConnectionId() !== cid) {
return callback.call(thisArg, { message: 'Connection Switched', status: -1 });
} else {
return callback.call(thisArg, err, result);
}
};
}
export default {
init: function() {
utilRebind(this, dispatch, 'on');
},
reset: function() {
_connectionID++;
_userChangesets = undefined;
_userDetails = undefined;
_rateLimitError = undefined;
_forEach(_tileCache.inflight, abortRequest);
_forEach(_noteCache.inflight, abortRequest);
_forEach(_noteCache.inflightPost, abortRequest);
if (_changeset.inflight) abortRequest(_changeset.inflight);
_tileCache = { loaded: {}, inflight: {}, seen: {} };
_noteCache = { loaded: {}, inflight: {}, inflightPost: {}, note: {}, rtree: rbush() };
_userCache = { toLoad: {}, user: {} };
_changeset = {};
return this;
},
getConnectionId: function() {
return _connectionID;
},
changesetURL: function(changesetID) {
return urlroot + '/changeset/' + changesetID;
},
changesetsURL: function(center, zoom) {
var precision = Math.max(0, Math.ceil(Math.log(zoom) / Math.LN2));
return urlroot + '/history#map=' +
Math.floor(zoom) + '/' +
center[1].toFixed(precision) + '/' +
center[0].toFixed(precision);
},
entityURL: function(entity) {
return urlroot + '/' + entity.type + '/' + entity.osmId();
},
historyURL: function(entity) {
return urlroot + '/' + entity.type + '/' + entity.osmId() + '/history';
},
userURL: function(username) {
return urlroot + '/user/' + username;
},
noteURL: function(note) {
return urlroot + '/note/' + note.id;
},
// Generic method to load data from the OSM API
// Can handle either auth or unauth calls.
loadFromAPI: function(path, callback, options) {
options = _extend({ skipSeen: true }, options);
var that = this;
var cid = _connectionID;
function done(err, xml) {
if (that.getConnectionId() !== cid) {
if (callback) callback({ message: 'Connection Switched', status: -1 });
return;
}
var isAuthenticated = that.authenticated();
// 400 Bad Request, 401 Unauthorized, 403 Forbidden
// Logout and retry the request..
if (isAuthenticated && err && (err.status === 400 || err.status === 401 || err.status === 403)) {
that.logout();
that.loadFromAPI(path, callback, options);
// else, no retry..
} else {
// 509 Bandwidth Limit Exceeded, 429 Too Many Requests
// Set the rateLimitError flag and trigger a warning..
if (!isAuthenticated && !_rateLimitError && err &&
(err.status === 509 || err.status === 429)) {
_rateLimitError = err;
dispatch.call('change');
}
if (callback) {
if (err) {
return callback(err);
} else {
return parseXML(xml, callback, options);
}
}
}
}
if (this.authenticated()) {
return oauth.xhr({ method: 'GET', path: path }, done);
} else {
var url = urlroot + path;
return d3_xml(url).get(done);
}
},
// Load a single entity by id (ways and relations use the `/full` call)
// GET /api/0.6/node/#id
// GET /api/0.6/[way|relation]/#id/full
loadEntity: function(id, callback) {
var type = osmEntity.id.type(id);
var osmID = osmEntity.id.toOSM(id);
var options = { skipSeen: false };
this.loadFromAPI(
'/api/0.6/' + type + '/' + osmID + (type !== 'node' ? '/full' : ''),
function(err, entities) {
if (callback) callback(err, { data: entities });
},
options
);
},
// Load a single entity with a specific version
// GET /api/0.6/[node|way|relation]/#id/#version
loadEntityVersion: function(id, version, callback) {
var type = osmEntity.id.type(id);
var osmID = osmEntity.id.toOSM(id);
var options = { skipSeen: false };
this.loadFromAPI(
'/api/0.6/' + type + '/' + osmID + '/' + version,
function(err, entities) {
if (callback) callback(err, { data: entities });
},
options
);
},
// Load multiple entities in chunks
// (note: callback may be called multiple times)
// GET /api/0.6/[nodes|ways|relations]?#parameters
loadMultiple: function(ids, callback) {
var that = this;
_forEach(_groupBy(_uniq(ids), osmEntity.id.type), function(v, k) {
var type = k + 's';
var osmIDs = _map(v, osmEntity.id.toOSM);
var options = { skipSeen: false };
_forEach(_chunk(osmIDs, 150), function(arr) {
that.loadFromAPI(
'/api/0.6/' + type + '?' + type + '=' + arr.join(),
function(err, entities) {
if (callback) callback(err, { data: entities });
},
options
);
});
});
},
// Create, upload, and close a changeset
// PUT /api/0.6/changeset/create
// POST /api/0.6/changeset/#id/upload
// PUT /api/0.6/changeset/#id/close
putChangeset: function(changeset, changes, callback) {
var cid = _connectionID;
if (_changeset.inflight) {
return callback({ message: 'Changeset already inflight', status: -2 }, changeset);
} else if (_changeset.open) { // reuse existing open changeset..
return createdChangeset.call(this, null, _changeset.open);
} else { // Open a new changeset..
var options = {
method: 'PUT',
path: '/api/0.6/changeset/create',
options: { header: { 'Content-Type': 'text/xml' } },
content: JXON.stringify(changeset.asJXON())
};
_changeset.inflight = oauth.xhr(
options,
wrapcb(this, createdChangeset, cid)
);
}
function createdChangeset(err, changesetID) {
_changeset.inflight = null;
if (err) { return callback(err, changeset); }
_changeset.open = changesetID;
changeset = changeset.update({ id: changesetID });
// Upload the changeset..
var options = {
method: 'POST',
path: '/api/0.6/changeset/' + changesetID + '/upload',
options: { header: { 'Content-Type': 'text/xml' } },
content: JXON.stringify(changeset.osmChangeJXON(changes))
};
_changeset.inflight = oauth.xhr(
options,
wrapcb(this, uploadedChangeset, cid)
);
}
function uploadedChangeset(err) {
_changeset.inflight = null;
if (err) return callback(err, changeset);
// Upload was successful, safe to call the callback.
// Add delay to allow for postgres replication #1646 #2678
window.setTimeout(function() { callback(null, changeset); }, 2500);
_changeset.open = null;
// At this point, we don't really care if the connection was switched..
// Only try to close the changeset if we're still talking to the same server.
if (this.getConnectionId() === cid) {
// Still attempt to close changeset, but ignore response because #2667
oauth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + changeset.id + '/close',
options: { header: { 'Content-Type': 'text/xml' } }
}, function() { return true; });
}
}
},
// Load multiple users in chunks
// (note: callback may be called multiple times)
// GET /api/0.6/users?users=#id1,#id2,...,#idn
loadUsers: function(uids, callback) {
var toLoad = [];
var cached = [];
_uniq(uids).forEach(function(uid) {
if (_userCache.user[uid]) {
delete _userCache.toLoad[uid];
cached.push(_userCache.user[uid]);
} else {
toLoad.push(uid);
}
});
if (cached.length || !this.authenticated()) {
callback(undefined, cached);
if (!this.authenticated()) return; // require auth
}
_chunk(toLoad, 150).forEach(function(arr) {
oauth.xhr(
{ method: 'GET', path: '/api/0.6/users?users=' + arr.join() },
wrapcb(this, done, _connectionID)
);
}.bind(this));
function done(err, xml) {
if (err) { return callback(err); }
var options = { skipSeen: true };
return parseXML(xml, function(err, results) {
if (err) {
return callback(err);
} else {
return callback(undefined, results);
}
}, options);
}
},
// Load a given user by id
// GET /api/0.6/user/#id
loadUser: function(uid, callback) {
if (_userCache.user[uid] || !this.authenticated()) { // require auth
delete _userCache.toLoad[uid];
return callback(undefined, _userCache.user[uid]);
}
oauth.xhr(
{ method: 'GET', path: '/api/0.6/user/' + uid },
wrapcb(this, done, _connectionID)
);
function done(err, xml) {
if (err) { return callback(err); }
var options = { skipSeen: true };
return parseXML(xml, function(err, results) {
if (err) {
return callback(err);
} else {
return callback(undefined, results[0]);
}
}, options);
}
},
// Load the details of the logged-in user
// GET /api/0.6/user/details
userDetails: function(callback) {
if (_userDetails) { // retrieve cached
return callback(undefined, _userDetails);
}
oauth.xhr(
{ method: 'GET', path: '/api/0.6/user/details' },
wrapcb(this, done, _connectionID)
);
function done(err, xml) {
if (err) { return callback(err); }
var options = { skipSeen: false };
return parseXML(xml, function(err, results) {
if (err) {
return callback(err);
} else {
_userDetails = results[0];
return callback(undefined, _userDetails);
}
}, options);
}
},
// Load previous changesets for the logged in user
// GET /api/0.6/changesets?user=#id
userChangesets: function(callback) {
if (_userChangesets) { // retrieve cached
return callback(undefined, _userChangesets);
}
this.userDetails(
wrapcb(this, gotDetails, _connectionID)
);
function gotDetails(err, user) {
if (err) { return callback(err); }
oauth.xhr(
{ method: 'GET', path: '/api/0.6/changesets?user=' + user.id },
wrapcb(this, done, _connectionID)
);
}
function done(err, xml) {
if (err) { return callback(err); }
_userChangesets = Array.prototype.map.call(
xml.getElementsByTagName('changeset'),
function (changeset) { return { tags: getTags(changeset) }; }
).filter(function (changeset) {
var comment = changeset.tags.comment;
return comment && comment !== '';
});
return callback(undefined, _userChangesets);
}
},
// Fetch the status of the OSM API
// GET /api/capabilities
status: function(callback) {
d3_xml(urlroot + '/api/capabilities').get(
wrapcb(this, done, _connectionID)
);
function done(err, xml) {
if (err) { return callback(err); }
// update blacklists
var elements = xml.getElementsByTagName('blacklist');
var regexes = [];
for (var i = 0; i < elements.length; i++) {
var regex = elements[i].getAttribute('regex'); // needs unencode?
if (regex) {
regexes.push(regex);
}
}
if (regexes.length) {
_blacklists = regexes;
}
if (_rateLimitError) {
return callback(_rateLimitError, 'rateLimited');
} else {
var apiStatus = xml.getElementsByTagName('status');
var val = apiStatus[0].getAttribute('api');
return callback(undefined, val);
}
}
},
// Load data (entities) from the API in tiles
// GET /api/0.6/map?bbox=
loadTiles: function(projection, callback) {
if (_off) return;
var that = this;
var path = '/api/0.6/map?bbox=';
// determine the needed tiles to cover the view
var tiles = tiler.zoomExtent([_tileZoom, _tileZoom]).getTiles(projection);
// abort inflight requests that are no longer needed
var hadRequests = !_isEmpty(_tileCache.inflight);
abortUnwantedRequests(_tileCache, tiles);
if (hadRequests && _isEmpty(_tileCache.inflight)) {
dispatch.call('loaded'); // stop the spinner
}
// issue new requests..
tiles.forEach(function(tile) {
if (_tileCache.loaded[tile.id] || _tileCache.inflight[tile.id]) return;
if (_isEmpty(_tileCache.inflight)) {
dispatch.call('loading'); // start the spinner
}
var options = { skipSeen: true };
_tileCache.inflight[tile.id] = that.loadFromAPI(
path + tile.extent.toParam(),
function(err, parsed) {
delete _tileCache.inflight[tile.id];
if (!err) {
_tileCache.loaded[tile.id] = true;
}
if (callback) {
callback(err, _extend({ data: parsed }, tile));
}
if (_isEmpty(_tileCache.inflight)) {
dispatch.call('loaded'); // stop the spinner
}
},
options
);
});
},
// Load notes from the API in tiles
// GET /api/0.6/notes?bbox=
loadNotes: function(projection, noteOptions) {
noteOptions = _extend({ limit: 10000, closed: 7 }, noteOptions);
if (_off) return;
var that = this;
var path = '/api/0.6/notes?limit=' + noteOptions.limit + '&closed=' + noteOptions.closed + '&bbox=';
var throttleLoadUsers = _throttle(function() {
var uids = Object.keys(_userCache.toLoad);
if (!uids.length) return;
that.loadUsers(uids, function() {}); // eagerly load user details
}, 750);
// determine the needed tiles to cover the view
var tiles = tiler.zoomExtent([_noteZoom, _noteZoom]).getTiles(projection);
// abort inflight requests that are no longer needed
abortUnwantedRequests(_noteCache, tiles);
// issue new requests..
tiles.forEach(function(tile) {
if (_noteCache.loaded[tile.id] || _noteCache.inflight[tile.id]) return;
var options = { skipSeen: false };
_noteCache.inflight[tile.id] = that.loadFromAPI(
path + tile.extent.toParam(),
function(err) {
delete _noteCache.inflight[tile.id];
if (!err) {
_noteCache.loaded[tile.id] = true;
}
throttleLoadUsers();
dispatch.call('loadedNotes');
},
options
);
});
},
// Create a note
// POST /api/0.6/notes?params
postNoteCreate: function(note, callback) {
if (!this.authenticated()) {
return callback({ message: 'Not Authenticated', status: -3 }, note);
}
if (_noteCache.inflightPost[note.id]) {
return callback({ message: 'Note update already inflight', status: -2 }, note);
}
if (!note.loc[0] || !note.loc[1] || !note.newComment) return; // location & description required
var comment = note.newComment;
if (note.newCategory && note.newCategory !== 'None') { comment += ' #' + note.newCategory; }
var path = '/api/0.6/notes?' + utilQsString({ lon: note.loc[0], lat: note.loc[1], text: comment });
_noteCache.inflightPost[note.id] = oauth.xhr(
{ method: 'POST', path: path },
wrapcb(this, done, _connectionID)
);
function done(err, xml) {
delete _noteCache.inflightPost[note.id];
if (err) { return callback(err); }
// we get the updated note back, remove from caches and reparse..
this.removeNote(note);
var options = { skipSeen: false };
return parseXML(xml, function(err, results) {
if (err) {
return callback(err);
} else {
return callback(undefined, results[0]);
}
}, options);
}
},
// Update a note
// POST /api/0.6/notes/#id/comment?text=comment
// POST /api/0.6/notes/#id/close?text=comment
// POST /api/0.6/notes/#id/reopen?text=comment
postNoteUpdate: function(note, newStatus, callback) {
if (!this.authenticated()) {
return callback({ message: 'Not Authenticated', status: -3 }, note);
}
if (_noteCache.inflightPost[note.id]) {
return callback({ message: 'Note update already inflight', status: -2 }, note);
}
var action;
if (note.status !== 'closed' && newStatus === 'closed') {
action = 'close';
} else if (note.status !== 'open' && newStatus === 'open') {
action = 'reopen';
} else {
action = 'comment';
if (!note.newComment) return; // when commenting, comment required
}
var path = '/api/0.6/notes/' + note.id + '/' + action;
if (note.newComment) {
path += '?' + utilQsString({ text: note.newComment });
}
_noteCache.inflightPost[note.id] = oauth.xhr(
{ method: 'POST', path: path },
wrapcb(this, done, _connectionID)
);
function done(err, xml) {
delete _noteCache.inflightPost[note.id];
if (err) { return callback(err); }
// we get the updated note back, remove from caches and reparse..
this.removeNote(note);
var options = { skipSeen: false };
return parseXML(xml, function(err, results) {
if (err) {
return callback(err);
} else {
return callback(undefined, results[0]);
}
}, options);
}
},
switch: function(options) {
urlroot = options.urlroot;
oauth.options(_extend({
url: urlroot,
loading: authLoading,
done: authDone
}, options));
this.reset();
this.userChangesets(function() {}); // eagerly load user details/changesets
dispatch.call('change');
return this;
},
toggle: function(_) {
_off = !_;
return this;
},
isChangesetInflight: function() {
return !!_changeset.inflight;
},
// get/set cached data
// This is used to save/restore the state when entering/exiting the walkthrough
// Also used for testing purposes.
caches: function(obj) {
if (!arguments.length) {
return {
tile: _cloneDeep(_tileCache),
note: _cloneDeep(_noteCache),
user: _cloneDeep(_userCache)
};
}
// access caches directly for testing (e.g., loading notes rtree)
if (obj === 'get') {
return {
tile: _tileCache,
note: _noteCache,
user: _userCache
};
}
if (obj.tile) {
_tileCache = obj.tile;
_tileCache.inflight = {};
}
if (obj.note) {
_noteCache = obj.note;
_noteCache.inflight = {};
_noteCache.inflightPost = {};
}
if (obj.user) {
_userCache = obj.user;
}
return this;
},
logout: function() {
_userChangesets = undefined;
_userDetails = undefined;
oauth.logout();
dispatch.call('change');
return this;
},
authenticated: function() {
return oauth.authenticated();
},
authenticate: function(callback) {
var that = this;
var cid = _connectionID;
_userChangesets = undefined;
_userDetails = undefined;
function done(err, res) {
if (err) {
if (callback) callback(err);
return;
}
if (that.getConnectionId() !== cid) {
if (callback) callback({ message: 'Connection Switched', status: -1 });
return;
}
_rateLimitError = undefined;
dispatch.call('change');
if (callback) callback(err, res);
that.userChangesets(function() {}); // eagerly load user details/changesets
}
return oauth.authenticate(done);
},
imageryBlacklists: function() {
return _blacklists;
},
tileZoom: function(_) {
if (!arguments.length) return _tileZoom;
_tileZoom = _;
return this;
},
// get all cached notes covering the viewport
notes: function(projection) {
var viewport = projection.clipExtent();
var min = [viewport[0][0], viewport[1][1]];
var max = [viewport[1][0], viewport[0][1]];
var bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
return _noteCache.rtree.search(bbox)
.map(function(d) { return d.data; });
},
// get a single note from the cache
getNote: function(id) {
return _noteCache.note[id];
},
// remove a single note from the cache
removeNote: function(note) {
if (!(note instanceof osmNote) || !note.id) return;
delete _noteCache.note[note.id];
updateRtree(encodeNoteRtree(note), false); // false = remove
},
// replace a single note in the cache
replaceNote: function(note) {
if (!(note instanceof osmNote) || !note.id) return;
_noteCache.note[note.id] = note;
updateRtree(encodeNoteRtree(note), true); // true = replace
return note;
}
};