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;
});
});