diff --git a/.gitignore b/.gitignore index 107779dad6..99c77f4963 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ land.html /css/img /test/css /test/img + +\.vscode/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 538530aa41..be802579e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -352,7 +352,7 @@ Drawing is then accomplished with .merge(footer); footer - .call(uiViewOnOSM(context).entityID(entityID)); + .call(uiViewOnOSM(context).what(entity)); ``` Some components are reconfigurable, and some provide functionality beyond diff --git a/css/65_data.css b/css/65_data.css new file mode 100644 index 0000000000..d672fccff5 --- /dev/null +++ b/css/65_data.css @@ -0,0 +1,162 @@ + +/* OSM Notes Layer */ +.layer-notes { + pointer-events: none; +} +.layer-notes .note * { + pointer-events: none; +} +.layer-notes .note .note-fill { + pointer-events: visible; + cursor: pointer; /* Opera */ + cursor: url(img/cursor-select-point.png), pointer; /* FF */ +} + +.note-header-icon .note-shadow, +.layer-notes .note .note-shadow { + color: #000; +} +.note-header-icon .note-fill, +.layer-notes .note .note-fill { + color: #ff3300; + stroke: #333; +} +.note-header-icon.closed .note-fill, +.layer-notes .note.closed .note-fill { + color: #55dd00; + stroke: #333; +} +.layer-notes .note.hovered .note-fill { + color: #eebb00; + stroke: #333; +} +.layer-notes .note.selected .note-fill { + color: #ffee00; + stroke: #333; +} + +/* slight adjustments to preset icon for note icons */ +.note-header-icon .preset-icon-28 { + top: 18px; +} + +.note-header-icon .note-icon-annotation { + position: absolute; + top: 21px; + left: 21px; + margin: auto; +} + +.note-header-icon .note-icon-annotation .icon { + width: 18px; + height: 18px; +} + + +/* OSM Note UI */ +.note-header { + background-color: #f6f6f6; + border-radius: 5px; + border: 1px solid #ccc; + display: flex; + flex-flow: row nowrap; + align-items: center; +} + +.note-header-icon { + background-color: #fff; + padding: 10px; + flex: 0 0 62px; + position: relative; + width: 60px; + height: 60px; + border-right: 1px solid #ccc; + border-radius: 5px 0 0 5px; +} +[dir='rtl'] .note-header-icon { + border-right: unset; + border-left: 1px solid #ccc; + border-radius: 0 5px 5px 0; +} + +.note-header-icon .icon-wrap { + position: absolute; + top: 0px; +} + +.note-header-label { + background-color: #f6f6f6; + padding: 0 15px; + flex: 1 1 100%; + font-size: 14px; + font-weight: bold; + border-radius: 0 5px 5px 0; +} +[dir='rtl'] .note-header-label { + border-radius: 5px 0 0 5px; +} + +.comments-container { + background: #ececec; + padding: 1px 10px; + margin: 10px 0; + border-radius: 8px; +} + +.comment { + background-color: #fff; + border-radius: 5px; + border: 1px solid #ccc; + margin: 10px auto; + display: flex; + flex-flow: row nowrap; +} +.comment-avatar { + padding: 10px; + flex: 0 0 62px; +} +.comment-avatar .icon.comment-avatar-icon { + width: 40px; + height: 40px; + object-fit: cover; + border: 1px solid #ccc; + border-radius: 20px; +} +.comment-main { + padding: 10px; + flex: 1 1 100%; + flex-flow: column nowrap; + overflow: hidden; + overflow-wrap: break-word; +} +.comment-metadata { + flex-flow: row nowrap; + justify-content: space-between; +} +.comment-author { + font-weight: bold; + color: #333; +} +.comment-date { + color: #aaa; +} +.comment-text { + color: #333; + margin-top: 10px; + overflow-y: auto; + max-height: 250px; +} +.comment-text::-webkit-scrollbar { + border-left: none; +} + +#new-comment-input { + width: 100%; + height: 100px; + max-height: 300px; + min-height: 100px; +} + +.note-report { + float: right; +} diff --git a/css/80_app.css b/css/80_app.css index 46eaa1da26..0dc9f87543 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -689,6 +689,7 @@ button.save.has-count .count::before { } .field-help-title button.close, +.sidebar-component .header button.note-editor-close, .entity-editor-pane .header button.preset-close, .preset-list-pane .header button.preset-choose { position: absolute; @@ -696,6 +697,7 @@ button.save.has-count .count::before { top: 0; } [dir='rtl'] .field-help-title button.close, +[dir='rtl'] .sidebar-component .header button.note-editor-close, [dir='rtl'] .entity-editor-pane .header button.preset-close, [dir='rtl'] .preset-list-pane .header button.preset-choose { left: 0; @@ -733,11 +735,21 @@ button.save.has-count .count::before { .footer { position: absolute; bottom: 0; + margin: 0; padding: 5px 20px 5px 20px; border-top: 1px solid #ccc; background-color: #fafafa; width: 100%; z-index: 1; + flex-wrap: wrap; + justify-content: space-between; + list-style: none; + display: flex; + +} + +.footer > a { + justify-content: center; } .sidebar-component .body { @@ -1290,6 +1302,7 @@ a.hide-toggle { .inspector-hover .preset-input-wrap .label, .inspector-hover .form-field-multicombo, .inspector-hover .structure-extras-wrap, +.inspector-hover .comments-container .comment, .inspector-hover input, .inspector-hover textarea, .inspector-hover label { @@ -1329,14 +1342,14 @@ a.hide-toggle { /* hide but preserve in layout */ .inspector-hover .entity-editor-pane button.minor, .inspector-hover .combobox-caret, -.inspector-hover .entity-editor-pane .header button, +.inspector-hover .header button, .inspector-hover .spin-control, .inspector-hover .form-field-multicombo .chips .remove, .inspector-hover .hide-toggle:before, .inspector-hover .more-fields, .inspector-hover .form-label-button-wrap, .inspector-hover .tag-reference-button, -.inspector-hover .view-on-osm { +.inspector-hover .footer * { opacity: 0; } @@ -3566,10 +3579,17 @@ img.tile-debug { vertical-align: middle; } +.save-section .buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + margin-bottom: 30px; +} + .save-section .buttons .action, .save-section .buttons .secondary-action { - display: inline-block; - margin: 0 20px 0 0; + width: 45%; + margin: 10px auto; text-align: center; vertical-align: middle; } diff --git a/data/core.yaml b/data/core.yaml index 5b544040b1..199a723ab6 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -441,6 +441,9 @@ en: osm: tooltip: Map data from OpenStreetMap title: OpenStreetMap data + notes: + tooltip: Note data from OpenStreetMap + title: OpenStreetMap notes fill_area: Fill Areas map_features: Map Features autohidden: "These features have been automatically hidden because too many would be shown on the screen. You can zoom in to edit them." @@ -607,6 +610,20 @@ en: title: "Photo Overlay (OpenStreetCam)" openstreetcam: view_on_openstreetcam: "View this image on OpenStreetCam" + note: + note: Note + title: Edit note + anonymous: anonymous + closed: "(Closed)" + commentTitle: Comments + newComment: New Comment + inputPlaceholder: Enter a comment to share with other users. + close: Close Note + open: Reopen Note + comment: Comment + close_comment: Close and Comment + open_comment: Reopen and Comment + report: Report help: title: Help key: H diff --git a/dist/locales/en.json b/dist/locales/en.json index 3412491a41..045ad80221 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -536,6 +536,10 @@ "osm": { "tooltip": "Map data from OpenStreetMap", "title": "OpenStreetMap data" + }, + "notes": { + "tooltip": "Note data from OpenStreetMap", + "title": "OpenStreetMap notes" } }, "fill_area": "Fill Areas", @@ -739,6 +743,21 @@ "openstreetcam": { "view_on_openstreetcam": "View this image on OpenStreetCam" }, + "note": { + "note": "Note", + "title": "Edit note", + "anonymous": "anonymous", + "closed": "(Closed)", + "commentTitle": "Comments", + "newComment": "New Comment", + "inputPlaceholder": "Enter a comment to share with other users.", + "close": "Close Note", + "open": "Reopen Note", + "comment": "Comment", + "close_comment": "Close and Comment", + "open_comment": "Reopen and Comment", + "report": "Report" + }, "help": { "title": "Help", "key": "H", diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index e57687369d..d086ac72c3 100644 --- a/modules/behavior/hover.js +++ b/modules/behavior/hover.js @@ -6,7 +6,10 @@ import { } from 'd3-selection'; import { d3keybinding as d3_keybinding } from '../lib/d3.keybinding.js'; -import { osmEntity } from '../osm'; +import { + osmEntity, + osmNote +} from '../osm'; import { utilRebind } from '../util/rebind'; @@ -108,7 +111,7 @@ export function behaviorHover(context) { .classed('hover-suppressed', false); var entity; - if (datum instanceof osmEntity) { + if (datum instanceof osmNote || datum instanceof osmEntity) { entity = datum; } else { entity = datum && datum.properties && datum.properties.entity; @@ -122,7 +125,7 @@ export function behaviorHover(context) { return; } - var selector = '.' + entity.id; + var selector = (datum instanceof osmNote) ? 'note-' + entity.id : '.' + entity.id; if (entity.type === 'relation') { entity.members.forEach(function(member) { @@ -135,7 +138,11 @@ export function behaviorHover(context) { _selection.selectAll(selector) .classed(suppressed ? 'hover-suppressed' : 'hover', true); - dispatch.call('hover', this, !suppressed && entity.id); + if (datum instanceof osmNote) { + dispatch.call('hover', this, !suppressed && entity); + } else { + dispatch.call('hover', this, !suppressed && entity.id); + } } else { dispatch.call('hover', this, null); diff --git a/modules/behavior/select.js b/modules/behavior/select.js index 9a9c0b34d2..9e0b3fee96 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -10,10 +10,14 @@ import { geoVecLength } from '../geo'; import { modeBrowse, - modeSelect + modeSelect, + modeSelectNote } from '../modes'; -import { osmEntity } from '../osm'; +import { + osmEntity, + osmNote +} from '../osm'; export function behaviorSelect(context) { @@ -122,15 +126,9 @@ export function behaviorSelect(context) { datum = datum.parents[0]; } - if (!(datum instanceof osmEntity)) { - // clicked nothing.. - if (!isMultiselect && mode.id !== 'browse') { - context.enter(modeBrowse(context)); - } - - } else { - // clicked an entity.. + if (datum instanceof osmEntity) { // clicked an entity.. var selectedIDs = context.selectedIDs(); + context.selectedNoteID(null); if (!isMultiselect) { if (selectedIDs.length > 1 && (!suppressMenu && !isShowAlways)) { @@ -158,6 +156,18 @@ export function behaviorSelect(context) { context.enter(modeSelect(context, selectedIDs).suppressMenu(suppressMenu)); } } + + } else if (datum instanceof osmNote && !isMultiselect) { // clicked a Note.. + context + .selectedNoteID(datum.id) + .enter(modeSelectNote(context, datum.id)); + + } else { // clicked nothing.. + + context.selectedNoteID(null); + if (!isMultiselect && mode.id !== 'browse') { + context.enter(modeBrowse(context)); + } } // reset for next time.. diff --git a/modules/core/context.js b/modules/core/context.js index 7e3ea68eb5..4d758066e8 100644 --- a/modules/core/context.js +++ b/modules/core/context.js @@ -255,10 +255,18 @@ export function coreContext() { return []; } }; + context.activeID = function() { return mode && mode.activeID && mode.activeID(); }; + var _selectedNoteID; + context.selectedNoteID = function(noteID) { + if (!arguments.length) return _selectedNoteID; + _selectedNoteID = noteID; + return context; + }; + /* Behaviors */ context.install = function(behavior) { diff --git a/modules/modes/index.js b/modules/modes/index.js index 4b2737be1b..ffcdf98685 100644 --- a/modules/modes/index.js +++ b/modules/modes/index.js @@ -9,3 +9,4 @@ export { modeMove } from './move'; export { modeRotate } from './rotate'; export { modeSave } from './save'; export { modeSelect } from './select'; +export { modeSelectNote } from './select_note'; diff --git a/modules/modes/select.js b/modules/modes/select.js index 1cb2b00020..924314de92 100644 --- a/modules/modes/select.js +++ b/modules/modes/select.js @@ -513,7 +513,6 @@ export function modeSelect(context, selectedIDs) { showMenu(); } }, 270); /* after any centerEase completes */ - }; diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js new file mode 100644 index 0000000000..8da727c8e5 --- /dev/null +++ b/modules/modes/select_note.js @@ -0,0 +1,123 @@ +import { + event as d3_event, + select as d3_select +} from 'd3-selection'; + +import { d3keybinding as d3_keybinding } from '../lib/d3.keybinding.js'; + +import { + behaviorHover, + behaviorLasso, + behaviorSelect +} from '../behavior'; + +import { services } from '../services'; +import { modeBrowse } from './browse'; +import { uiNoteEditor } from '../ui'; + + +export function modeSelectNote(context, selectedNoteID) { + var mode = { + id: 'select_note', + button: 'browse' + }; + + var osm = services.osm; + var keybinding = d3_keybinding('select-note'); + var noteEditor = uiNoteEditor(context) + .on('change', function() { + context.map().pan([0,0]); // trigger a redraw + var note = checkSelectedID(); + if (!note) return; + context.ui().sidebar + .show(noteEditor.note(note)); + }); + + var behaviors = [ + behaviorHover(context), + behaviorSelect(context), + behaviorLasso(context), + ]; + + + function checkSelectedID() { + if (!osm) return; + var note = osm.getNote(selectedNoteID); + if (!note) { + context.enter(modeBrowse(context)); + } + return note; + } + + + mode.enter = function() { + + // class the note as selected, or return to browse mode if the note is gone + function selectNote(drawn) { + if (!checkSelectedID()) return; + + var selection = context.surface() + .selectAll('.note-' + selectedNoteID); + + if (selection.empty()) { + // Return to browse mode if selected DOM elements have + // disappeared because the user moved them out of view.. + var source = d3_event && d3_event.type === 'zoom' && d3_event.sourceEvent; + if (drawn && source && (source.type === 'mousemove' || source.type === 'touchmove')) { + context.enter(modeBrowse(context)); + } + + } else { + selection + .classed('selected', true); + } + } + + function esc() { + context.enter(modeBrowse(context)); + } + + var note = checkSelectedID(); + if (!note) return; + + behaviors.forEach(function(behavior) { + context.install(behavior); + }); + + keybinding + .on('⎋', esc, true); + + d3_select(document) + .call(keybinding); + + context.ui().sidebar + .show(noteEditor.note(note)); + + context.map() + .on('drawn.select', selectNote); + + selectNote(); + }; + + + mode.exit = function() { + behaviors.forEach(function(behavior) { + context.uninstall(behavior); + }); + + keybinding.off(); + + context.surface() + .selectAll('.note.selected') + .classed('selected hovered', false); + + context.map() + .on('drawn.select', null); + + context.ui().sidebar + .hide(); + }; + + + return mode; +} diff --git a/modules/osm/index.js b/modules/osm/index.js index 528f679eff..bbbb4835ac 100644 --- a/modules/osm/index.js +++ b/modules/osm/index.js @@ -1,6 +1,7 @@ export { osmChangeset } from './changeset'; export { osmEntity } from './entity'; export { osmNode } from './node'; +export { osmNote } from './note'; export { osmRelation } from './relation'; export { osmWay } from './way'; diff --git a/modules/osm/note.js b/modules/osm/note.js new file mode 100644 index 0000000000..3e10e8cf1b --- /dev/null +++ b/modules/osm/note.js @@ -0,0 +1,60 @@ +import _extend from 'lodash-es/extend'; + +import { geoExtent } from '../geo'; + + +export function osmNote() { + if (!(this instanceof osmNote)) { + return (new osmNote()).initialize(arguments); + } else if (arguments.length) { + this.initialize(arguments); + } +} + + +osmNote.id = function() { + return osmNote.id.next--; +}; + + +osmNote.id.next = -1; + + +_extend(osmNote.prototype, { + + type: 'note', + + initialize: function(sources) { + for (var i = 0; i < sources.length; ++i) { + var source = sources[i]; + for (var prop in source) { + if (Object.prototype.hasOwnProperty.call(source, prop)) { + if (source[prop] === undefined) { + delete this[prop]; + } else { + this[prop] = source[prop]; + } + } + } + } + + if (!this.id) { + this.id = osmNote.id(); + } + + return this; + }, + + extent: function() { + return new geoExtent(this.loc); + }, + + update: function(attrs) { + return osmNote(this, attrs, {v: 1 + (this.v || 0)}); + }, + + isNew: function() { + return this.id < 0; + } + +}); diff --git a/modules/renderer/map.js b/modules/renderer/map.js index 10adf2c3df..1118488b7f 100644 --- a/modules/renderer/map.js +++ b/modules/renderer/map.js @@ -350,7 +350,7 @@ export function rendererMap(context) { surface.selectAll('.layer-osm *').remove(); var mode = context.mode(); - if (mode && mode.id !== 'save') { + if (mode && mode.id !== 'save' && mode.id !== 'select_note') { context.enter(modeBrowse(context)); } diff --git a/modules/services/osm.js b/modules/services/osm.js index 9ead1f5b0c..7e9faf49d0 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -1,4 +1,5 @@ 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 _filter from 'lodash-es/filter'; @@ -6,26 +7,35 @@ 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 { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; -import { geoExtent } from '../geo'; +import { geoExtent, geoVecAdd } from '../geo'; + import { osmEntity, osmNode, + osmNote, osmRelation, osmWay } from '../osm'; -import { utilRebind, utilIdleWorker } from '../util'; +import { + utilRebind, + utilIdleWorker, + utilQsString +} from '../util'; -var dispatch = d3_dispatch('authLoading', 'authDone', 'change', 'loading', 'loaded'); +var dispatch = d3_dispatch('authLoading', 'authDone', 'change', 'loading', 'loaded', 'loadedNotes'); var urlroot = 'https://www.openstreetmap.org'; var oauth = osmAuth({ url: urlroot, @@ -36,11 +46,14 @@ var oauth = osmAuth({ }); var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; -var _tiles = { loaded: {}, inflight: {} }; +var _tileCache = { loaded: {}, inflight: {}, seen: {} }; +var _noteCache = { loaded: {}, inflight: {}, inflightPost: {}, note: {}, rtree: rbush() }; +var _userCache = { toLoad: {}, user: {} }; var _changeset = {}; -var _entityCache = {}; + var _connectionID = 1; var _tileZoom = 16; +var _noteZoom = 12; var _rateLimitError; var _userChangesets; var _userDetails; @@ -113,11 +126,44 @@ function getVisible(attrs) { } +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; +} + + var parsers = { node: function nodeData(obj, uid) { var attrs = obj.attributes; return new osmNode({ - id:uid, + id: uid, visible: getVisible(attrs), version: attrs.version.value, changeset: attrs.changeset && attrs.changeset.value, @@ -157,29 +203,133 @@ var parsers = { 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 = { minX: note.loc[0], minY: note.loc[1], maxX: note.loc[0], maxY: note.loc[1], data: note }; + _noteCache.rtree.insert(item); + _noteCache.note[note.id] = note; + 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 parse(xml, callback, options) { - options = _extend({ cache: true }, options); - if (!xml || !xml.childNodes) return; +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) { - var uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); - if (options.cache && _entityCache[uid]) { + 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; } - return parser(child, uid); + + } 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); } +} - utilIdleWorker(children, parseChild, callback); + +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); + } + }; } @@ -195,11 +345,17 @@ export default { _userChangesets = undefined; _userDetails = undefined; _rateLimitError = undefined; - _forEach(_tiles.inflight, abortRequest); + + _forEach(_tileCache.inflight, abortRequest); + _forEach(_noteCache.inflight, abortRequest); + _forEach(_noteCache.inflightPost, abortRequest); if (_changeset.inflight) abortRequest(_changeset.inflight); - _tiles = { loaded: {}, inflight: {} }; + + _tileCache = { loaded: {}, inflight: {}, seen: {} }; + _noteCache = { loaded: {}, inflight: {}, inflightPost: {}, note: {}, rtree: rbush() }; + _userCache = { toLoad: {}, user: {} }; _changeset = {}; - _entityCache = {}; + return this; }, @@ -209,8 +365,8 @@ export default { }, - changesetURL: function(changesetId) { - return urlroot + '/changeset/' + changesetId; + changesetURL: function(changesetID) { + return urlroot + '/changeset/' + changesetID; }, @@ -238,8 +394,15 @@ export default { }, + 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({ cache: true }, options); + options = _extend({ skipSeen: true }, options); var that = this; var cid = _connectionID; @@ -255,7 +418,7 @@ export default { // Logout and retry the request.. if (isAuthenticated && err && (err.status === 400 || err.status === 401 || err.status === 403)) { that.logout(); - that.loadFromAPI(path, callback); + that.loadFromAPI(path, callback, options); // else, no retry.. } else { @@ -268,15 +431,11 @@ export default { } if (callback) { - if (err) return callback(err, null); - parse(xml, function (entities) { - if (options.cache) { - for (var i in entities) { - _entityCache[entities[i].id] = true; - } - } - callback(null, entities); - }, options); + if (err) { + return callback(err); + } else { + return parseXML(xml, callback, options); + } } } } @@ -290,10 +449,13 @@ export default { }, + // 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 = { cache: false }; + var options = { skipSeen: false }; this.loadFromAPI( '/api/0.6/' + type + '/' + osmID + (type !== 'node' ? '/full' : ''), @@ -305,10 +467,12 @@ export default { }, + // 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 = { cache: false }; + var options = { skipSeen: false }; this.loadFromAPI( '/api/0.6/' + type + '/' + osmID + '/' + version, @@ -320,13 +484,16 @@ export default { }, + // 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 = { cache: false }; + var options = { skipSeen: false }; _forEach(_chunk(osmIDs, 150), function(arr) { that.loadFromAPI( @@ -341,74 +508,66 @@ export default { }, - authenticated: function() { - return oauth.authenticated(); - }, - - + // 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); - } - var that = this; - var cid = _connectionID; + } else if (_changeset.open) { // reuse existing open changeset.. + return createdChangeset(null, _changeset.open); - if (_changeset.open) { // reuse existing open changeset.. - createdChangeset(null, _changeset.open); - } else { // open a new changeset.. - _changeset.inflight = oauth.xhr({ + } 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()) - }, createdChangeset); + }; + _changeset.inflight = oauth.xhr( + options, + wrapcb(this, createdChangeset, cid) + ); } function createdChangeset(err, changesetID) { _changeset.inflight = null; - - if (err) { - // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. - if (err.status === 400 || err.status === 401 || err.status === 403) { - that.logout(); - } - return callback(err, changeset); - } - if (that.getConnectionId() !== cid) { - return callback({ message: 'Connection Switched', status: -1 }, changeset); - } + if (err) { return callback(err, changeset); } _changeset.open = changesetID; changeset = changeset.update({ id: changesetID }); // Upload the changeset.. - _changeset.inflight = oauth.xhr({ + var options = { method: 'POST', path: '/api/0.6/changeset/' + changesetID + '/upload', options: { header: { 'Content-Type': 'text/xml' } }, content: JXON.stringify(changeset.osmChangeJXON(changes)) - }, uploadedChangeset); + }; + _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); - + 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 (that.getConnectionId() === cid) { + if (this.getConnectionId() === cid) { // Still attempt to close changeset, but ignore response because #2667 oauth.xhr({ method: 'PUT', @@ -420,112 +579,151 @@ export default { }, - userDetails: function(callback) { - if (_userDetails) { - callback(undefined, _userDetails); - return; + // 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 } - var that = this; - var cid = _connectionID; + _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, user_details) { - if (err) { - // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. - if (err.status === 400 || err.status === 401 || err.status === 403) { - that.logout(); + 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); } - return callback(err); - } - if (that.getConnectionId() !== cid) { - return callback({ message: 'Connection Switched', status: -1 }); - } + }, options); + } + }, - var u = user_details.getElementsByTagName('user')[0]; - var img = u.getElementsByTagName('img'); - var image_url = ''; + // 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]); + } - if (img && img[0] && img[0].getAttribute('href')) { - image_url = img[0].getAttribute('href'); - } + oauth.xhr( + { method: 'GET', path: '/api/0.6/user/' + uid }, + wrapcb(this, done, _connectionID) + ); - var changesets = u.getElementsByTagName('changesets'); - var changesets_count = 0; + function done(err, xml) { + if (err) { return callback(err); } - if (changesets && changesets[0] && changesets[0].getAttribute('count')) { - changesets_count = changesets[0].getAttribute('count'); - } + var options = { skipSeen: true }; + return parseXML(xml, function(err, results) { + if (err) { + return callback(err); + } else { + return callback(undefined, results[0]); + } + }, options); + } + }, - _userDetails = { - id: u.attributes.id.value, - display_name: u.attributes.display_name.value, - image_url: image_url, - changesets_count: changesets_count - }; - callback(undefined, _userDetails); + // 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' }, done); + 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) { - callback(undefined, _userChangesets); - return; + if (_userChangesets) { // retrieve cached + return callback(undefined, _userChangesets); } - var that = this; - var cid = _connectionID; + this.userDetails( + wrapcb(this, gotDetails, _connectionID) + ); - this.userDetails(function(err, user) { - if (err) { - return callback(err); - } - if (that.getConnectionId() !== cid) { - return callback({ message: 'Connection Switched', status: -1 }); - } - function done(err, changesets) { - if (err) { - // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. - if (err.status === 400 || err.status === 401 || err.status === 403) { - that.logout(); - } - return callback(err); - } - if (that.getConnectionId() !== cid) { - return callback({ message: 'Connection Switched', status: -1 }); - } + function gotDetails(err, user) { + if (err) { return callback(err); } - _userChangesets = Array.prototype.map.call( - changesets.getElementsByTagName('changeset'), - function (changeset) { - return { tags: getTags(changeset) }; - } - ).filter(function (changeset) { - var comment = changeset.tags.comment; - return comment && comment !== ''; - }); + oauth.xhr( + { method: 'GET', path: '/api/0.6/changesets?user=' + user.id }, + wrapcb(this, done, _connectionID) + ); + } - callback(undefined, _userChangesets); - } + 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 !== ''; + }); - oauth.xhr({ method: 'GET', path: '/api/0.6/changesets?user=' + user.id }, done); - }); + return callback(undefined, _userChangesets); + } }, + // Fetch the status of the OSM API + // GET /api/capabilities status: function(callback) { - var that = this; - var cid = _connectionID; + d3_xml(urlroot + '/api/capabilities').get( + wrapcb(this, done, _connectionID) + ); - function done(xml) { - if (that.getConnectionId() !== cid) { - return callback({ message: 'Connection Switched', status: -1 }, 'connectionSwitched'); - } + function done(err, xml) { + if (err) { return callback(err); } // update blacklists var elements = xml.getElementsByTagName('blacklist'); @@ -540,102 +738,190 @@ export default { _blacklists = regexes; } - if (_rateLimitError) { - callback(_rateLimitError, 'rateLimited'); + return callback(_rateLimitError, 'rateLimited'); } else { var apiStatus = xml.getElementsByTagName('status'); var val = apiStatus[0].getAttribute('api'); - - callback(undefined, val); + return callback(undefined, val); } } - - d3_xml(urlroot + '/api/capabilities').get() - .on('load', done) - .on('error', callback); - }, - - - imageryBlacklists: function() { - return _blacklists; - }, - - - tileZoom: function(_) { - if (!arguments.length) return _tileZoom; - _tileZoom = _; - return this; }, - loadTiles: function(projection, dimensions, callback) { + // Load data (entities or notes) from the API in tiles + // GET /api/0.6/map?bbox= + // GET /api/0.6/notes?bbox= + loadTiles: function(projection, dimensions, callback, noteOptions) { if (_off) return; var that = this; + + // are we loading entities or notes? + var loadingNotes = (noteOptions !== undefined); + var path, cache, tilezoom, throttleLoadUsers; + + if (loadingNotes) { + noteOptions = _extend({ limit: 10000, closed: 7}, noteOptions); + path = '/api/0.6/notes?limit=' + noteOptions.limit + '&closed=' + noteOptions.closed + '&bbox='; + cache = _noteCache; + tilezoom = _noteZoom; + throttleLoadUsers = _throttle(function() { + var uids = Object.keys(_userCache.toLoad); + if (!uids.length) return; + that.loadUsers(uids, function() {}); // eagerly load user details + }, 750); + } else { + path = '/api/0.6/map?bbox='; + cache = _tileCache; + tilezoom = _tileZoom; + } + var s = projection.scale() * 2 * Math.PI; var z = Math.max(Math.log(s) / Math.log(2) - 8, 0); - var ts = 256 * Math.pow(2, z - _tileZoom); + var ts = 256 * Math.pow(2, z - tilezoom); var origin = [ s / 2 - projection.translate()[0], s / 2 - projection.translate()[1] ]; - var tiles = d3_geoTile() - .scaleExtent([_tileZoom, _tileZoom]) + // what tiles cover the view? + var tiler = d3_geoTile() + .scaleExtent([tilezoom, tilezoom]) .scale(s) .size(dimensions) - .translate(projection.translate())() - .map(function(tile) { - var x = tile[0] * ts - origin[0]; - var y = tile[1] * ts - origin[1]; - - return { - id: tile.toString(), - extent: geoExtent( - projection.invert([x, y + ts]), - projection.invert([x + ts, y])) - }; - }); + .translate(projection.translate()); + + var tiles = tiler().map(function(tile) { + var x = tile[0] * ts - origin[0]; + var y = tile[1] * ts - origin[1]; + + return { + id: tile.toString(), + extent: geoExtent( + projection.invert([x, y + ts]), + projection.invert([x + ts, y]) + ) + }; + }); - _filter(_tiles.inflight, function(v, i) { - var wanted = _find(tiles, function(tile) { - return i === tile.id; - }); - if (!wanted) delete _tiles.inflight[i]; + // remove inflight requests that no longer cover the view.. + var hadRequests = !_isEmpty(cache.inflight); + _filter(cache.inflight, function(v, i) { + var wanted = _find(tiles, function(tile) { return i === tile.id; }); + if (!wanted) { + delete cache.inflight[i]; + } return !wanted; }).map(abortRequest); - tiles.forEach(function(tile) { - var id = tile.id; - - if (_tiles.loaded[id] || _tiles.inflight[id]) return; + if (hadRequests && !loadingNotes && _isEmpty(cache.inflight)) { + dispatch.call('loaded'); // stop the spinner + } - if (_isEmpty(_tiles.inflight)) { - dispatch.call('loading'); + // issue new requests.. + tiles.forEach(function(tile) { + if (cache.loaded[tile.id] || cache.inflight[tile.id]) return; + if (!loadingNotes && _isEmpty(cache.inflight)) { + dispatch.call('loading'); // start the spinner } - _tiles.inflight[id] = that.loadFromAPI( - '/api/0.6/map?bbox=' + tile.extent.toParam(), + var options = { skipSeen: !loadingNotes }; + cache.inflight[tile.id] = that.loadFromAPI( + path + tile.extent.toParam(), function(err, parsed) { - delete _tiles.inflight[id]; + delete cache.inflight[tile.id]; if (!err) { - _tiles.loaded[id] = true; + cache.loaded[tile.id] = true; } - if (callback) { - callback(err, _extend({ data: parsed }, tile)); - } + if (loadingNotes) { + throttleLoadUsers(); + dispatch.call('loadedNotes'); - if (_isEmpty(_tiles.inflight)) { - dispatch.call('loaded'); + } else { + if (callback) { + callback(err, _extend({ data: parsed }, tile)); + } + if (_isEmpty(cache.inflight)) { + dispatch.call('loaded'); // stop the spinner + } } - } + }, + options ); }); }, + // Load notes from the API (just calls this.loadTiles) + // GET /api/0.6/notes?bbox= + loadNotes: function(projection, dimensions, noteOptions) { + noteOptions = _extend({ limit: 10000, closed: 7}, noteOptions); + this.loadTiles(projection, dimensions, null, noteOptions); + }, + + + // Create a note + // POST /api/0.6/notes?params + postNoteCreate: function(note, callback) { + // todo + }, + + + // 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'; + } + + 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.. + var item = { minX: note.loc[0], minY: note.loc[1], maxX: note.loc[0], maxY: note.loc[1], data: note }; + _noteCache.rtree.remove(item, function isEql(a, b) { return a.data.id === b.data.id; }); + delete _noteCache.note[note.id]; + + 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; @@ -658,9 +944,40 @@ export default { }, - loadedTiles: function(_) { - if (!arguments.length) return _tiles.loaded; - _tiles.loaded = _; + // 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; }, @@ -674,6 +991,11 @@ export default { }, + authenticated: function() { + return oauth.authenticated(); + }, + + authenticate: function(callback) { var that = this; var cid = _connectionID; @@ -696,5 +1018,45 @@ export default { } 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]; + }, + + + // replace a single note in the cache + replaceNote: function(n) { + if (n instanceof osmNote) { + _noteCache.note[n.id] = n; + } + return n; } + }; diff --git a/modules/svg/index.js b/modules/svg/index.js index 41e9f2b341..caf82041a0 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -9,6 +9,7 @@ export { svgLines } from './lines.js'; export { svgMapillaryImages } from './mapillary_images.js'; export { svgMapillarySigns } from './mapillary_signs.js'; export { svgMidpoints } from './midpoints.js'; +export { svgNotes } from './notes.js'; export { svgOneWaySegments } from './helpers.js'; export { svgOpenstreetcamImages } from './openstreetcam_images.js'; export { svgOsm } from './osm.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 3f2ff4816d..9b717f82ce 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -13,6 +13,7 @@ import { svgMapillaryImages } from './mapillary_images'; import { svgMapillarySigns } from './mapillary_signs'; import { svgOpenstreetcamImages } from './openstreetcam_images'; import { svgOsm } from './osm'; +import { svgNotes } from './notes'; import { utilRebind } from '../util/rebind'; import { utilGetDimensions, utilSetDimensions } from '../util/dimensions'; @@ -22,6 +23,7 @@ export function svgLayers(projection, context) { var svg = d3_select(null); var layers = [ { id: 'osm', layer: svgOsm(projection, context, dispatch) }, + { id: 'notes', layer: svgNotes(projection, context, dispatch) }, { id: 'gpx', layer: svgGpx(projection, context, dispatch) }, { id: 'streetside', layer: svgStreetside(projection, context, dispatch)}, { id: 'mapillary-images', layer: svgMapillaryImages(projection, context, dispatch) }, diff --git a/modules/svg/notes.js b/modules/svg/notes.js new file mode 100644 index 0000000000..b416e8ee4a --- /dev/null +++ b/modules/svg/notes.js @@ -0,0 +1,173 @@ +import _throttle from 'lodash-es/throttle'; + +import { select as d3_select } from 'd3-selection'; + +import { svgPointTransform } from './index'; +import { services } from '../services'; + + +export function svgNotes(projection, context, dispatch) { + var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + var minZoom = 12; + var layer = d3_select(null); + var _notes; + + + function init() { + if (svgNotes.initialized) return; // run once + svgNotes.enabled = false; + svgNotes.initialized = true; + } + + function editOn() { + layer.style('display', 'block'); + } + + + function editOff() { + layer.selectAll('.note').remove(); + layer.style('display', 'none'); + } + + + function getService() { + if (services.osm && !_notes) { + _notes = services.osm; + _notes.on('loadedNotes', throttledRedraw); + } else if (!services.osm && _notes) { + _notes = null; + } + + return _notes; + } + + + function showLayer() { + editOn(); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', function () { dispatch.call('change'); }); + } + + + function hideLayer() { + throttledRedraw.cancel(); + + layer + .transition() + .duration(250) + .style('opacity', 0) + .on('end', editOff); + } + + + function update() { + var service = getService(); + var selectedID = context.selectedNoteID(); + var data = (service ? service.notes(projection) : []); + var transform = svgPointTransform(projection); + var notes = layer.selectAll('.note') + .data(data, function(d) { return d.status + d.id; }); + + // exit + notes.exit() + .remove(); + + // enter + var notesEnter = notes.enter() + .append('g') + .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }); + + // notesEnter + // .append('use') + // .attr('class', 'note-shadow') + // .attr('width', '24px') + // .attr('height', '24px') + // .attr('x', '-12px') + // .attr('y', '-24px') + // .attr('xlink:href', '#iD-icon-note'); + + notesEnter + .append('use') + .attr('class', 'note-fill') + .attr('width', '20px') + .attr('height', '20px') + .attr('x', '-10px') + .attr('y', '-22px') + .attr('xlink:href', '#iD-icon-note'); + + // add dots if there's a comment thread + notesEnter.selectAll('.note-annotation') + .data(function(d) { return d.comments.length > 1 ? [0] : []; }) + .enter() + .append('use') + .attr('class', 'note-annotation thread') + .attr('width', '14px') + .attr('height', '14px') + .attr('x', '-7px') + .attr('y', '-20px') + .attr('xlink:href', '#iD-icon-more'); + + // update + notes + .merge(notesEnter) + .sort(function(a, b) { + return (a.id === selectedID) ? 1 + : (b.id === selectedID) ? -1 + : b.loc[1] - a.loc[1]; // sort Y + }) + .classed('selected', function(d) { return d.id === selectedID; }) + .attr('transform', transform); + } + + + function drawNotes(selection) { + var enabled = svgNotes.enabled; + var service = getService(); + + layer = selection.selectAll('.layer-notes') + .data(service ? [0] : []); + + layer.exit() + .remove(); + + layer.enter() + .append('g') + .attr('class', 'layer-notes') + .style('display', enabled ? 'block' : 'none') + .merge(layer); + + function dimensions() { + return [window.innerWidth, window.innerHeight]; + } + + if (enabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadNotes(projection, dimensions()); + update(); + } else { + editOff(); + } + } + } + + drawNotes.enabled = function(_) { + if (!arguments.length) return svgNotes.enabled; + svgNotes.enabled = _; + if (svgNotes.enabled) { + showLayer(); + } else { + hideLayer(); + } + dispatch.call('change'); + return this; + }; + + init(); + return drawNotes; +} diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 3b934898cb..ec79e7baf5 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -220,14 +220,14 @@ export function uiCommit(context) { buttonEnter .append('button') - .attr('class', 'secondary-action col5 button cancel-button') + .attr('class', 'secondary-action button cancel-button') .append('span') .attr('class', 'label') .text(t('commit.cancel')); buttonEnter .append('button') - .attr('class', 'action col5 button save-button') + .attr('class', 'action button save-button') .append('span') .attr('class', 'label') .text(t('commit.save')); diff --git a/modules/ui/index.js b/modules/ui/index.js index d908b70783..35d9a85176 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -34,6 +34,10 @@ export { uiMapInMap } from './map_in_map'; export { uiModal } from './modal'; export { uiModes } from './modes'; export { uiNotice } from './notice'; +export { uiNoteComments } from './note_comments'; +export { uiNoteEditor } from './note_editor'; +export { uiNoteHeader } from './note_header'; +export { uiNoteReport } from './note_report'; export { uiPresetEditor } from './preset_editor'; export { uiPresetIcon } from './preset_icon'; export { uiPresetList } from './preset_list'; diff --git a/modules/ui/inspector.js b/modules/ui/inspector.js index 89d1f28ed1..f4785ddc39 100644 --- a/modules/ui/inspector.js +++ b/modules/ui/inspector.js @@ -44,11 +44,12 @@ export function uiInspector(context) { var presetPane = wrap.selectAll('.preset-list-pane'); var editorPane = wrap.selectAll('.entity-editor-pane'); - var graph = context.graph(), - entity = context.entity(_entityID), - showEditor = _state === 'hover' || - entity.isUsed(graph) || - entity.isHighwayIntersection(graph); + var graph = context.graph(); + var entity = context.entity(_entityID); + + var showEditor = _state === 'hover' || + entity.isUsed(graph) || + entity.isHighwayIntersection(graph); if (showEditor) { wrap.style('right', '0%'); @@ -67,7 +68,9 @@ export function uiInspector(context) { .merge(footer); footer - .call(uiViewOnOSM(context).entityID(_entityID)); + .call(uiViewOnOSM(context) + .what(context.hasEntity(_entityID)) + ); function showList(preset) { diff --git a/modules/ui/intro/intro.js b/modules/ui/intro/intro.js index 9243c45473..3dcf675375 100644 --- a/modules/ui/intro/intro.js +++ b/modules/ui/intro/intro.js @@ -71,7 +71,7 @@ export function uiIntro(context) { var background = context.background().baseLayerSource(); var overlays = context.background().overlayLayerSources(); var opacity = d3_selectAll('#map .layer-background').style('opacity'); - var loadedTiles = osm && osm.loadedTiles(); + var caches = osm && osm.caches(); var baseEntities = context.history().graph().base().entities; var countryCode = services.geocoder.countryCode; @@ -147,7 +147,7 @@ export function uiIntro(context) { curtain.remove(); navwrap.remove(); d3_selectAll('#map .layer-background').style('opacity', opacity); - if (osm) { osm.toggle(true).reset().loadedTiles(loadedTiles); } + if (osm) { osm.toggle(true).reset().caches(caches); } context.history().reset().merge(_values(baseEntities)); context.background().baseLayerSource(background); overlays.forEach(function (d) { context.background().toggleOverlayLayer(d); }); diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 32e1eac190..47f215d550 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -86,7 +86,7 @@ export function uiMapData(context) { function drawPhotoItems(selection) { - var photoKeys = ['streetside','mapillary-images', 'mapillary-signs', 'openstreetcam-images']; + var photoKeys = ['streetside', 'mapillary-images', 'mapillary-signs', 'openstreetcam-images']; var photoLayers = layers.all().filter(function(obj) { return photoKeys.indexOf(obj.id) !== -1; }); var data = photoLayers.filter(function(obj) { return obj.layer.supported(); }); @@ -147,58 +147,64 @@ export function uiMapData(context) { } - function drawOsmItem(selection) { - var osm = layers.layer('osm'), - showsOsm = osm.enabled(); + function drawOsmItems(selection) { + var osmKeys = ['osm', 'notes']; + var osmLayers = layers.all().filter(function(obj) { return osmKeys.indexOf(obj.id) !== -1; }); var ul = selection .selectAll('.layer-list-osm') - .data(osm ? [0] : []); - - // Exit - ul.exit() - .remove(); + .data([0]); - // Enter - var ulEnter = ul.enter() + ul = ul.enter() .append('ul') - .attr('class', 'layer-list layer-list-osm'); + .attr('class', 'layer-list layer-list-osm') + .merge(ul); - var liEnter = ulEnter + var li = ul.selectAll('.list-item') + .data(osmLayers); + + li.exit() + .remove(); + + var liEnter = li.enter() .append('li') - .attr('class', 'list-item-osm'); + .attr('class', function(d) { return 'list-item list-item-' + d.id; }); var labelEnter = liEnter .append('label') - .call(tooltip() - .title(t('map_data.layers.osm.tooltip')) - .placement('bottom') - ); + .each(function(d) { + d3_select(this) + .call(tooltip() + .title(t('map_data.layers.' + d.id + '.tooltip')) + .placement('bottom') + ); + }); labelEnter .append('input') .attr('type', 'checkbox') - .on('change', function() { toggleLayer('osm'); }); + .on('change', function(d) { toggleLayer(d.id); }); labelEnter .append('span') - .text(t('map_data.layers.osm.title')); + .text(function(d) { return t('map_data.layers.' + d.id + '.title'); }); + // Update - ul = ul - .merge(ulEnter); + li = li + .merge(liEnter); - ul.selectAll('.list-item-osm') - .classed('active', showsOsm) + li + .classed('active', function (d) { return d.layer.enabled(); }) .selectAll('input') - .property('checked', showsOsm); + .property('checked', function (d) { return d.layer.enabled(); }); } function drawGpxItem(selection) { - var gpx = layers.layer('gpx'), - hasGpx = gpx && gpx.hasGpx(), - showsGpx = hasGpx && gpx.enabled(); + var gpx = layers.layer('gpx'); + var hasGpx = gpx && gpx.hasGpx(); + var showsGpx = hasGpx && gpx.enabled(); var ul = selection .selectAll('.layer-list-gpx') @@ -367,7 +373,7 @@ export function uiMapData(context) { function update() { _dataLayerContainer - .call(drawOsmItem) + .call(drawOsmItems) .call(drawPhotoItems) .call(drawGpxItem); diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js new file mode 100644 index 0000000000..a9890779cc --- /dev/null +++ b/modules/ui/note_comments.js @@ -0,0 +1,116 @@ +import { select as d3_select } from 'd3-selection'; + +import { t } from '../util/locale'; +import { svgIcon } from '../svg'; +import { services } from '../services'; +import { utilDetect } from '../util/detect'; + + +export function uiNoteComments() { + var _note; + + + function noteComments(selection) { + var comments = selection.selectAll('.comments-container') + .data([0]); + + comments = comments.enter() + .append('div') + .attr('class', 'comments-container') + .merge(comments); + + var commentEnter = comments.selectAll('.comment') + .data(_note.comments) + .enter() + .append('div') + .attr('class', 'comment'); + + commentEnter + .append('div') + .attr('class', function(d) { return 'comment-avatar user-' + d.uid; }) + .call(svgIcon('#iD-icon-avatar', 'comment-avatar-icon')); + + var mainEnter = commentEnter + .append('div') + .attr('class', 'comment-main'); + + var metadataEnter = mainEnter + .append('div') + .attr('class', 'comment-metadata'); + + metadataEnter + .append('div') + .attr('class', 'comment-author') + .each(function(d) { + var selection = d3_select(this); + var osm = services.osm; + if (osm && d.user) { + selection = selection + .append('a') + .attr('class', 'comment-author-link') + .attr('href', osm.userURL(d.user)) + .attr('tabindex', -1) + .attr('target', '_blank'); + } + selection + .text(function(d) { return d.user || t('note.anonymous'); }); + }); + + metadataEnter + .append('div') + .attr('class', 'comment-date') + .text(function(d) { return d.action + ' ' + localeDateString(d.date); }); + + mainEnter + .append('div') + .attr('class', 'comment-text') + .text(function(d) { return d.text; }); + + comments + .call(replaceAvatars); + } + + + function replaceAvatars(selection) { + var osm = services.osm; + if (!osm) return; + + var uids = {}; // gather uids in the comment thread + _note.comments.forEach(function(d) { + if (d.uid) uids[d.uid] = true; + }); + + Object.keys(uids).forEach(function(uid) { + osm.loadUser(uid, function(err, user) { + if (!user || !user.image_url) return; + + selection.selectAll('.comment-avatar.user-' + uid) + .html('') + .append('img') + .attr('class', 'icon comment-avatar-icon') + .attr('src', user.image_url) + .attr('alt', user.display_name); + }); + }); + } + + + function localeDateString(s) { + if (!s) return null; + var detected = utilDetect(); + var options = { day: 'numeric', month: 'short', year: 'numeric' }; + var d = new Date(s); + if (isNaN(d.getTime())) return null; + return d.toLocaleDateString(detected.locale, options); + } + + + noteComments.note = function(_) { + if (!arguments.length) return _note; + _note = _; + return noteComments; + }; + + + return noteComments; +} diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js new file mode 100644 index 0000000000..4a15e3adc2 --- /dev/null +++ b/modules/ui/note_editor.js @@ -0,0 +1,201 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { select as d3_select } from 'd3-selection'; + +import { t } from '../util/locale'; +import { services } from '../services'; +import { modeBrowse } from '../modes'; +import { svgIcon } from '../svg'; + +import { + uiNoteComments, + uiNoteHeader, + uiNoteReport, + uiViewOnOSM, +} from './index'; + +import { + utilNoAuto, + utilRebind +} from '../util'; + + +export function uiNoteEditor(context) { + var dispatch = d3_dispatch('change'); + var noteComments = uiNoteComments(); + var noteHeader = uiNoteHeader(); + var _note; + + + function noteEditor(selection) { + var header = selection.selectAll('.header') + .data([0]); + + var headerEnter = header.enter() + .append('div') + .attr('class', 'header fillL'); + + headerEnter + .append('button') + .attr('class', 'fr note-editor-close') + .on('click', function() { context.enter(modeBrowse(context)); }) + .call(svgIcon('#iD-icon-close')); + + headerEnter + .append('h3') + .text(t('note.title')); + + + var body = selection.selectAll('.body') + .data([0]); + + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + + body.selectAll('.note-editor') + .data([0]) + .enter() + .append('div') + .attr('class', 'modal-section note-editor') + .call(noteHeader.note(_note)) + .call(noteComments.note(_note)) + .call(noteSave); + + + selection.selectAll('.footer') + .data([0]) + .enter() + .append('div') + .attr('class', 'footer') + .call(uiViewOnOSM(context).what(_note)) + .call(uiNoteReport(context).note(_note)); + } + + + function noteSave(selection) { + var isSelected = (_note && _note.id === context.selectedNoteID()); + var noteSave = selection.selectAll('.note-save-section') + .data((isSelected ? [_note] : []), function(d) { return d.status + d.id; }); + + // exit + noteSave.exit() + .remove(); + + // enter + var noteSaveEnter = noteSave.enter() + .append('div') + .attr('class', 'note-save-section save-section cf'); + + noteSaveEnter + .append('h4') + .attr('class', '.note-save-header') + .text(t('note.newComment')); + + noteSaveEnter + .append('textarea') + .attr('id', 'new-comment-input') + .attr('placeholder', t('note.inputPlaceholder')) + .attr('maxlength', 1000) + .property('value', function(d) { return d.newComment; }) + .call(utilNoAuto) + .on('input', change) + .on('blur', change); + + // update + noteSave = noteSaveEnter + .merge(noteSave) + .call(noteSaveButtons); + + + function change() { + var input = d3_select(this); + var val = input.property('value').trim() || undefined; + + // store the unsaved comment with the note itself + _note = _note.update({ newComment: val }); + + var osm = services.osm; + if (osm) { + osm.replaceNote(_note); // update note cache + } + + noteSave + .call(noteSaveButtons); + } + } + + + function noteSaveButtons(selection) { + var isSelected = (_note && _note.id === context.selectedNoteID()); + var buttonSection = selection.selectAll('.buttons') + .data((isSelected ? [_note] : []), function(d) { return d.status + d.id; }); + + // exit + buttonSection.exit() + .remove(); + + // enter + var buttonEnter = buttonSection.enter() + .append('div') + .attr('class', 'buttons'); + + buttonEnter + .append('button') + .attr('class', 'button status-button action') + .append('span') + .attr('class', 'label'); + + buttonEnter + .append('button') + .attr('class', 'button comment-button action') + .append('span') + .attr('class', 'label') + .text(t('note.comment')); + + // update + buttonSection = buttonSection + .merge(buttonEnter); + + buttonSection.select('.status-button') // select and propagate data + .text(function(d) { + var action = (d.status === 'open' ? 'close' : 'open'); + var andComment = (d.newComment ? '_comment' : ''); + return t('note.' + action + andComment); + }) + .on('click.status', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + var osm = services.osm; + if (osm) { + var setStatus = (d.status === 'open' ? 'closed' : 'open'); + osm.postNoteUpdate(d, setStatus, function(err, note) { + dispatch.call('change', note); + }); + } + }); + + buttonSection.select('.comment-button') // select and propagate data + .attr('disabled', function(d) { + return (d.status === 'open' && d.newComment) ? null : true; + }) + .on('click.save', function(d) { + this.blur(); // avoid keeping focus on the button - #4641 + var osm = services.osm; + if (osm) { + osm.postNoteUpdate(d, d.status, function(err, note) { + dispatch.call('change', note); + }); + } + }); + } + + + noteEditor.note = function(_) { + if (!arguments.length) return _note; + _note = _; + return noteEditor; + }; + + + return utilRebind(noteEditor, dispatch, 'on'); +} diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js new file mode 100644 index 0000000000..dcaf82d461 --- /dev/null +++ b/modules/ui/note_header.js @@ -0,0 +1,59 @@ +import { t } from '../util/locale'; +import { svgIcon } from '../svg'; + + +export function uiNoteHeader() { + var _note; + + + function noteHeader(selection) { + var header = selection.selectAll('.note-header') + .data( + (_note ? [_note] : []), + function(d) { return d.status + d.id; } + ); + + header.exit() + .remove(); + + var headerEnter = header.enter() + .append('div') + .attr('class', 'note-header'); + + var iconEnter = headerEnter + .append('div') + .attr('class', function(d) { return 'note-header-icon ' + d.status; }); + + iconEnter + .append('div') + .attr('class', 'preset-icon-28') + .call(svgIcon('#iD-icon-note', 'note-fill')); + + iconEnter.each(function(d) { + if (d.comments.length > 1) { + iconEnter + .append('div') + .attr('class', 'note-icon-annotation') + .call(svgIcon('#iD-icon-more', 'note-annotation')); + } + }); + + headerEnter + .append('div') + .attr('class', 'note-header-label') + .text(function(d) { + return t('note.note') + ' ' + d.id + ' ' + + (d.status === 'closed' ? t('note.closed') : ''); + }); + } + + + noteHeader.note = function(_) { + if (!arguments.length) return _note; + _note = _; + return noteHeader; + }; + + + return noteHeader; +} diff --git a/modules/ui/note_report.js b/modules/ui/note_report.js new file mode 100644 index 0000000000..759f9ba135 --- /dev/null +++ b/modules/ui/note_report.js @@ -0,0 +1,47 @@ +import { t } from '../util/locale'; +import { svgIcon } from '../svg'; +import { + osmNote +} from '../osm'; + + +export function uiNoteReport() { + var _note; + var url = 'https://www.openstreetmap.org/reports/new?reportable_id='; + + function noteReport(selection) { + + if (!(_note instanceof osmNote)) return; + + url += _note.id + '&reportable_type=Note'; + + var data = ((!_note || _note.isNew()) ? [] : [_note]); + var link = selection.selectAll('.note-report') + .data(data, function(d) { return d.id; }); + + // exit + link.exit() + .remove(); + + // enter + var linkEnter = link.enter() + .append('a') + .attr('class', 'note-report') + .attr('target', '_blank') + .attr('href', url) + .call(svgIcon('#iD-icon-out-link', 'inline')); + + linkEnter + .append('span') + .text(t('note.report')); + } + + + noteReport.note = function(_) { + if (!arguments.length) return _note; + _note = _; + return noteReport; + }; + + return noteReport; +} diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 0062b70317..256c775f8c 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -1,11 +1,19 @@ import _throttle from 'lodash-es/throttle'; + +import { selectAll as d3_selectAll } from 'd3-selection'; + +import { osmNote } from '../osm'; import { uiFeatureList } from './feature_list'; import { uiInspector } from './inspector'; +import { uiNoteEditor } from './note_editor'; export function uiSidebar(context) { - var inspector = uiInspector(context), - current; + var inspector = uiInspector(context); + var noteEditor = uiNoteEditor(context); + var _current; + var _wasNote = false; + // var layer = d3_select(null); function sidebar(selection) { @@ -20,8 +28,19 @@ export function uiSidebar(context) { .attr('class', 'inspector-hidden inspector-wrap fr'); - function hover(id) { - if (!current && context.hasEntity(id)) { + function hover(what) { + if ((what instanceof osmNote)) { + _wasNote = true; + var notes = d3_selectAll('.note'); + notes + .classed('hovered', function(d) { return d === what; }); + + sidebar.show(noteEditor.note(what)); + + selection.selectAll('.sidebar-component') + .classed('inspector-hover', true); + + } else if (!_current && context.hasEntity(what)) { featureListWrap .classed('inspector-hidden', true); @@ -29,22 +48,28 @@ export function uiSidebar(context) { .classed('inspector-hidden', false) .classed('inspector-hover', true); - if (inspector.entityID() !== id || inspector.state() !== 'hover') { + if (inspector.entityID() !== what || inspector.state() !== 'hover') { inspector .state('hover') - .entityID(id); + .entityID(what); inspectorWrap .call(inspector); } - } else if (!current) { + } else if (!_current) { featureListWrap .classed('inspector-hidden', false); inspectorWrap .classed('inspector-hidden', true); inspector .state('hide'); + + } else if (_wasNote) { + _wasNote = false; + d3_selectAll('.note') + .classed('hovered', false); + sidebar.hide(); } } @@ -53,7 +78,7 @@ export function uiSidebar(context) { sidebar.select = function(id, newFeature) { - if (!current && id) { + if (!_current && id) { featureListWrap .classed('inspector-hidden', true); @@ -71,7 +96,7 @@ export function uiSidebar(context) { .call(inspector); } - } else if (!current) { + } else if (!_current) { featureListWrap .classed('inspector-hidden', false); inspectorWrap @@ -82,17 +107,17 @@ export function uiSidebar(context) { }; - sidebar.show = function(component) { + sidebar.show = function(component, element) { featureListWrap .classed('inspector-hidden', true); inspectorWrap .classed('inspector-hidden', true); - if (current) current.remove(); - current = selection + if (_current) _current.remove(); + _current = selection .append('div') .attr('class', 'sidebar-component') - .call(component); + .call(component, element); }; @@ -102,8 +127,8 @@ export function uiSidebar(context) { inspectorWrap .classed('inspector-hidden', true); - if (current) current.remove(); - current = null; + if (_current) _current.remove(); + _current = null; }; } diff --git a/modules/ui/view_on_osm.js b/modules/ui/view_on_osm.js index 146c95d4da..b013f158b9 100644 --- a/modules/ui/view_on_osm.js +++ b/modules/ui/view_on_osm.js @@ -1,37 +1,48 @@ import { t } from '../util/locale'; import { svgIcon } from '../svg'; +import { + osmEntity, + osmNote +} from '../osm'; export function uiViewOnOSM(context) { - var id; + var _what; // an osmEntity or osmNote - function viewOnOSM(selection) { - var entity = context.entity(id); - - selection.style('display', entity.isNew() ? 'none' : null); + function viewOnOSM(selection) { + var url; + if (_what instanceof osmEntity) { + url = context.connection().entityURL(_what); + } else if (_what instanceof osmNote) { + url = context.connection().noteURL(_what); + } + + var data = ((!_what || _what.isNew()) ? [] : [_what]); var link = selection.selectAll('.view-on-osm') - .data([0]); + .data(data, function(d) { return d.id; }); - var enter = link.enter() + // exit + link.exit() + .remove(); + + // enter + var linkEnter = link.enter() .append('a') .attr('class', 'view-on-osm') .attr('target', '_blank') + .attr('href', url) .call(svgIcon('#iD-icon-out-link', 'inline')); - enter + linkEnter .append('span') .text(t('inspector.view_on_osm')); - - link - .merge(enter) - .attr('href', context.connection().entityURL(entity)); } - viewOnOSM.entityID = function(_) { - if (!arguments.length) return id; - id = _; + viewOnOSM.what = function(_) { + if (!arguments.length) return _what; + _what = _; return viewOnOSM; }; diff --git a/svg/iD-sprite/icons/icon-note.svg b/svg/iD-sprite/icons/icon-note.svg new file mode 100644 index 0000000000..11033f7071 --- /dev/null +++ b/svg/iD-sprite/icons/icon-note.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/test/index.html b/test/index.html index 188b9084c0..602943c8d0 100644 --- a/test/index.html +++ b/test/index.html @@ -89,6 +89,7 @@ + diff --git a/test/spec/osm/note.js b/test/spec/osm/note.js new file mode 100644 index 0000000000..4e3d248177 --- /dev/null +++ b/test/spec/osm/note.js @@ -0,0 +1,15 @@ +describe('iD.osmNote', function () { + it('returns a note', function () { + expect(iD.osmNote()).to.be.an.instanceOf(iD.osmNote); + expect(iD.osmNote().type).to.equal('note'); + }); + + describe('#extent', function() { + it('returns a note extent', function() { + expect(iD.osmNote({loc: [5, 10]}).extent().equals([[5, 10], [5, 10]])).to.be.ok; + }); + }); + + // TODO: add tests for #update, or remove function + +}); \ No newline at end of file diff --git a/test/spec/services/osm.js b/test/spec/services/osm.js index 3f51916f14..bc1617b4c0 100644 --- a/test/spec/services/osm.js +++ b/test/spec/services/osm.js @@ -137,8 +137,8 @@ describe('iD.serviceOsm', function () { }); describe('#loadFromAPI', function () { - var path = '/api/0.6/map?bbox=-74.542,40.655,-74.541,40.656', - response = '' + + var path = '/api/0.6/map?bbox=-74.542,40.655,-74.541,40.656'; + var response = '' + '' + ' ' + @@ -290,14 +290,14 @@ describe('iD.serviceOsm', function () { ); server.respond(); }); - }); + describe('#loadEntity', function () { var nodeXML = '' + '' + - '', - wayXML = '' + + ''; + var wayXML = '' + '' + '' + ''; @@ -355,11 +355,12 @@ describe('iD.serviceOsm', function () { }); }); + describe('#loadEntityVersion', function () { var nodeXML = '' + '' + - '', - wayXML = '' + + ''; + var wayXML = '' + '' + ''; @@ -416,6 +417,7 @@ describe('iD.serviceOsm', function () { }); }); + describe('#loadMultiple', function () { beforeEach(function() { server = sinon.fakeServer.create(); @@ -428,7 +430,6 @@ describe('iD.serviceOsm', function () { it('loads nodes'); it('loads ways'); it('does not ignore repeat requests'); - }); @@ -536,6 +537,146 @@ describe('iD.serviceOsm', function () { }); + describe('#caches', function() { + it('loads reset caches', function (done) { + var resetCaches = { + tile: { + inflight: {}, loaded: {}, seen: {} + }, + note: { + loaded: {}, inflight: {}, inflightPost: {}, note: {} // not including rtree + }, + user: { + toLoad: {}, user: {} + } + }; + var caches = connection.caches(); + expect(caches.tile).to.eql(resetCaches.tile); + expect(caches.note.loaded).to.eql(resetCaches.note.loaded); + expect(caches.user).to.eql(resetCaches.user); + done(); + }); + + describe('sets/gets caches', function() { + it('sets/gets a tile', function (done) { + var obj = { + tile: { loaded: { '1,2,16': true, '3,4,16': true } } + }; + connection.caches(obj); + expect(connection.caches().tile.loaded['1,2,16']).to.eql(true); + expect(Object.keys(connection.caches().tile.loaded).length).to.eql(2); + done(); + }); + + it('sets/gets a note', function (done) { + var note = iD.osmNote({ id: 1, loc: [0, 0] }); + var note2 = iD.osmNote({ id: 2, loc: [0, 0] }); + var obj = { + note: { note: { 1: note, 2: note2 } } + }; + connection.caches(obj); + expect(connection.caches().note.note[note.id]).to.eql(note); + expect(Object.keys(connection.caches().note.note).length).to.eql(2); + done(); + }); + + it('sets/gets a user', function (done) { + var user = { id: 1, display_name: 'Name' }; + var user2 = { id: 2, display_name: 'Name' }; + var obj = { + user: { user: { 1: user, 2: user2 } } + }; + connection.caches(obj); + expect(connection.caches().user.user[user.id]).to.eql(user); + expect(Object.keys(connection.caches().user.user).length).to.eql(2); + done(); + }); + }); + + }); + + describe('#loadNotes', function() { + beforeEach(function() { + context.projection + .scale(116722210.56960216) + .translate([244505613.61327893, 74865520.92230521]) + .clipExtent([[0,0], [609.34375, 826]]); + }); + + it('fires loadedNotes when notes are loaded', function() { + connection.on('loadedNotes', spy); + connection.loadNotes(context.projection, [64, 64], {}); + + var url = 'http://www.openstreetmap.org/api/0.6/notes?limit=10000&closed=7&bbox=-120.05859375,34.45221847282654,-119.970703125,34.52466147177173'; + var notesXML = ''; // TODO: determine output even though this test note is closed and will be gone soon + + server.respondWith('GET', url, + [200, { 'Content-Type': 'text/xml' }, notesXML ]); + server.respond(); + + expect(spy).to.have.been.calledOnce; + }); + }); + + + describe('#notes', function() { + beforeEach(function() { + var dimensions = [64, 64]; + context.projection + .scale(667544.214430109) // z14 + .translate([-116508, 0]) // 10,0 + .clipExtent([[0,0], dimensions]); + }); + it('returns notes in the visible map area', function() { + var notes = [ + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { key: '0', loc: [10,0] } }, + { minX: 10, minY: 0, maxX: 10, maxY: 0, data: { key: '1', loc: [10,0] } }, + { minX: 10, minY: 1, maxX: 10, maxY: 1, data: { key: '2', loc: [10,1] } } + ]; + + connection.caches('get').note.rtree.load(notes); + var res = connection.notes(context.projection); + + expect(res).to.deep.eql([ + { key: '0', loc: [10,0] }, + { key: '1', loc: [10,0] } + ]); + }); + }); + + + describe('#getNote', function() { + it('returns a note', function (done) { + var note = iD.osmNote({ id: 1, loc: [0, 0], }); + var obj = { + note: { note: { 1: note } } + }; + connection.caches(obj); + var result = connection.getNote(1); + expect(result).to.deep.equal(note); + done(); + }); + }); + + + describe('#replaceNote', function() { + it('returns a new note', function (done) { + var note = iD.osmNote({ id: 2, loc: [0, 0], }); + var result = connection.replaceNote(note); + expect(result.id).to.eql(2); + done(); + }); + + it('replaces a note', function (done) { + var note = iD.osmNote({ id: 2, loc: [0, 0], }); + connection.replaceNote(note); + note.status = 'closed'; + var result = connection.replaceNote(note); + expect(result.status).to.eql('closed'); + done(); + }); + }); + describe('API capabilities', function() { var capabilitiesXML = '' + diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index e01c76d475..2869007a7a 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,14 +26,15 @@ describe('iD.svgLayers', function () { it('creates default data layers', function () { container.call(iD.svgLayers(projection, context)); var nodes = container.selectAll('svg .data-layer').nodes(); - expect(nodes.length).to.eql(7); + expect(nodes.length).to.eql(8); expect(d3.select(nodes[0]).classed('data-layer-osm')).to.be.true; - expect(d3.select(nodes[1]).classed('data-layer-gpx')).to.be.true; - expect(d3.select(nodes[2]).classed('data-layer-streetside')).to.be.true; - expect(d3.select(nodes[3]).classed('data-layer-mapillary-images')).to.be.true; - expect(d3.select(nodes[4]).classed('data-layer-mapillary-signs')).to.be.true; - expect(d3.select(nodes[5]).classed('data-layer-openstreetcam-images')).to.be.true; - expect(d3.select(nodes[6]).classed('data-layer-debug')).to.be.true; + expect(d3.select(nodes[1]).classed('data-layer-notes')).to.be.true; + expect(d3.select(nodes[2]).classed('data-layer-gpx')).to.be.true; + expect(d3.select(nodes[3]).classed('data-layer-streetside')).to.be.true; + expect(d3.select(nodes[4]).classed('data-layer-mapillary-images')).to.be.true; + expect(d3.select(nodes[5]).classed('data-layer-mapillary-signs')).to.be.true; + expect(d3.select(nodes[6]).classed('data-layer-openstreetcam-images')).to.be.true; + expect(d3.select(nodes[7]).classed('data-layer-debug')).to.be.true; }); });