From f92bc63600b2d14b52633d64b53726240d9eefc9 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 19 Jun 2018 09:58:49 -0400 Subject: [PATCH 01/77] init commit: updated locales/en after build --- dist/locales/en.json | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/dist/locales/en.json b/dist/locales/en.json index c0ba6ad9d..99f3714bf 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -6858,6 +6858,13 @@ }, "name": "OpenStreetMap (German Style)" }, + "osmse-ekonomiska": { + "attribution": { + "text": "© Lantmäteriet" + }, + "description": "Scan of ´Economic maps´ ca 1950-1980", + "name": "Lantmäteriet Economic Map (historic)" + }, "qa_no_address": { "attribution": { "text": "Simon Poole, Data ©OpenStreetMap contributors" @@ -6887,6 +6894,48 @@ "text": "Maps © Thunderforest, Data © OpenStreetMap contributors" }, "name": "Thunderforest Landscape" + }, + "trafikverket-baninfo": { + "attribution": { + "text": "© Trafikverket, CC0" + }, + "description": "Swedish railway network, including sidings", + "name": "Trafikverket Railway Network" + }, + "trafikverket-baninfo-option": { + "attribution": { + "text": "© Trafikverket, CC0" + }, + "description": "Swedish railway network with several options for map layers", + "name": "Trafikverket Railway Network options" + }, + "trafikverket-vagnat": { + "attribution": { + "text": "© Trafikverket, CC0" + }, + "description": "Swedish NVDB road network", + "name": "Trafikverket Road Network" + }, + "trafikverket-vagnat-extra": { + "attribution": { + "text": "© Trafikverket, CC0" + }, + "description": "Swedish NVDB extra details: Highway reference, traffic calming, rest area, bus stop, bridge, tunnel, speed camera", + "name": "Trafikverket Road Network extra" + }, + "trafikverket-vagnat-navn": { + "attribution": { + "text": "© Trafikverket, CC0" + }, + "description": "Swedish NVDB street names", + "name": "Trafikverket Street Names" + }, + "trafikverket-vagnat-option": { + "attribution": { + "text": "© Trafikverket, CC0" + }, + "description": "Swedish NVDB road network with several options for map layers", + "name": "Trafikverket Road Network options" } }, "community": { From e3c0fd4f56ccb50bd161df5559d879917d935382 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 19 Jun 2018 10:06:40 -0400 Subject: [PATCH 02/77] added: .vscode debug config to ignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5f7ebb0f3..a576c9bee 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ land.html /css/img /test/css /test/img + +.vscode/launch.json From 1ed915e69c7d81a1b12e3e998068636f169c1fd3 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 19 Jun 2018 10:31:26 -0400 Subject: [PATCH 03/77] added: map data notes toggle --- data/core.yaml | 3 +++ dist/locales/en.json | 4 ++++ modules/svg/index.js | 1 + modules/svg/layers.js | 2 ++ modules/svg/notes.js | 30 ++++++++++++++++++++++++++ modules/ui/map_data.js | 48 +++++++++++++++++++++++++++++++++++++++++ test/spec/svg/layers.js | 15 +++++++------ 7 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 modules/svg/notes.js diff --git a/data/core.yaml b/data/core.yaml index c10fa4c7c..7da04749c 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." diff --git a/dist/locales/en.json b/dist/locales/en.json index 99f3714bf..8f41f4092 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", diff --git a/modules/svg/index.js b/modules/svg/index.js index 41e9f2b34..caf82041a 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 3f2ff4816..9b717f82c 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 000000000..8663d1f37 --- /dev/null +++ b/modules/svg/notes.js @@ -0,0 +1,30 @@ +export function svgNotes(projection, context, dispatch) { + var enabled = false; + + + function drawNotes() { + } + + function showLayer() { + } + + + function hideLayer() { + } + + drawNotes.enabled = function(_) { + if (!arguments.length) return enabled; + enabled = _; + + if (enabled) { + showLayer(); + } else { + hideLayer(); + } + + dispatch.call('change'); + return this; + }; + + return drawNotes; +} diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 32e1eac19..1c46dcf96 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -194,6 +194,53 @@ export function uiMapData(context) { .property('checked', showsOsm); } + function drawNotesItem(selection) { + var notes = layers.layer('notes'), + showsNotes = notes.enabled(); + + var ul = selection + .selectAll('.layer-list-notes') + .data(notes ? [0] : []); + + // Exit + ul.exit() + .remove(); + + // Enter + var ulEnter = ul.enter() + .append('ul') + .attr('class', 'layer-list layer-list-notes'); + + var liEnter = ulEnter + .append('li') + .attr('class', 'list-item-notes'); + + var labelEnter = liEnter + .append('label') + .call(tooltip() + .title(t('map_data.layers.notes.tooltip')) + .placement('top') + ); + + labelEnter + .append('input') + .attr('type', 'checkbox') + .on('change', function () { toggleLayer('notes'); }); + + labelEnter + .append('span') + .text(t('map_data.layers.notes.title')); + + // Update + ul = ul + .merge(ulEnter); + + ul.selectAll('.list-item-notes') + .classed('active', showsNotes) + .selectAll('input') + .property('checked', showsNotes); + } + function drawGpxItem(selection) { var gpx = layers.layer('gpx'), @@ -368,6 +415,7 @@ export function uiMapData(context) { function update() { _dataLayerContainer .call(drawOsmItem) + .call(drawNotesItem) .call(drawPhotoItems) .call(drawGpxItem); diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index e01c76d47..2869007a7 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; }); }); From 3de1fd8e6f94f01ce3ea133168a9cdf839ddeef7 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 19 Jun 2018 10:45:58 -0400 Subject: [PATCH 04/77] reformatted svg/notes --- modules/svg/notes.js | 49 ++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 8663d1f37..070dd3b49 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -1,30 +1,31 @@ export function svgNotes(projection, context, dispatch) { - var enabled = false; + var enabled = false; + function drawNotes() { - function drawNotes() { - } - - function showLayer() { - } - - - function hideLayer() { - } - - drawNotes.enabled = function(_) { - if (!arguments.length) return enabled; - enabled = _; - - if (enabled) { - showLayer(); - } else { - hideLayer(); } - dispatch.call('change'); - return this; - }; + function showLayer() { - return drawNotes; -} + } + + function hideLayer() { + + } + + drawNotes.enabled = function(_) { + if (!arguments.length) return enabled; + enabled = _; + + if (enabled) { + showLayer(); + } else { + hideLayer(); + } + + dispatch('change'); + return this; + }; + + return drawNotes; +} \ No newline at end of file From cf2a9ad93516d2cf9a96b336fa06adf22482a90c Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 19 Jun 2018 17:01:56 -0400 Subject: [PATCH 05/77] added: notes api service, parse and callback stubs --- modules/services/index.js | 2 + modules/services/notes.js | 175 ++++++++++++++++++++++++++++++++++++++ modules/svg/notes.js | 106 +++++++++++++++++++++-- 3 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 modules/services/notes.js diff --git a/modules/services/index.js b/modules/services/index.js index 789628ed5..646ba2e7a 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,5 +1,6 @@ import serviceMapillary from './mapillary'; import serviceNominatim from './nominatim'; +import serviceNotes from './notes'; import serviceOpenstreetcam from './openstreetcam'; import serviceOsm from './osm'; import serviceStreetside from './streetside'; @@ -10,6 +11,7 @@ import serviceWikipedia from './wikipedia'; export var services = { geocoder: serviceNominatim, mapillary: serviceMapillary, + notes: serviceNotes, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, streetside: serviceStreetside, diff --git a/modules/services/notes.js b/modules/services/notes.js new file mode 100644 index 000000000..dc6664550 --- /dev/null +++ b/modules/services/notes.js @@ -0,0 +1,175 @@ +import _extend from 'lodash-es/extend'; +import _filter from 'lodash-es/filter'; +import _find from 'lodash-es/find'; +import _forEach from 'lodash-es/forEach'; +import _isEmpty from 'lodash-es/isEmpty'; + +import osmAuth from 'osm-auth'; + +import rbush from 'rbush'; + +var _entityCache = {}; + +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { xml as d3_xml } from 'd3-request'; + +import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; +import { geoExtent } from '../geo'; + +import { + utilRebind, + utilIdleWorker +} from '../util'; + +var urlroot = 'https://api.openstreetmap.org', + _notesCache = { notes: { inflight: {}, loaded: {} } }, + __notesSelectedNote, + dispatch = d3_dispatch('loadedNotes', 'loading'), + tileZoom = 14; + +var oauth = osmAuth({ + url: urlroot, + oauth_consumer_key: '5A043yRSEugj4DJ5TljuapfnrflWDte8jTOcWLlT', + oauth_secret: 'aB3jKq1TRsCOUrfOIZ6oQMEDmv2ptV76PA54NGLL', + loading: authLoading, + done: authDone +}); + +function authLoading() { + dispatch.call('authLoading'); +} + + +function authDone() { + dispatch.call('authDone'); +} + +function abortRequest(i) { + i.abort(); +} + +function getTiles(projection) { + var s = projection.scale() * 2 * Math.PI, + z = Math.max(Math.log(s) / Math.log(2) - 8, 0), + ts = 256 * Math.pow(2, z - tileZoom), + origin = [ + s / 2 - projection.translate()[0], + s / 2 - projection.translate()[1]]; + + return d3_geoTile() + .scaleExtent([tileZoom, tileZoom]) + .scale(s) + .size(projection.clipExtent()[1]) + .translate(projection.translate())() + .map(function(tile) { + var x = tile[0] * ts - origin[0], + y = tile[1] * ts - origin[1]; + + return { + id: tile.toString(), + xyz: tile, + extent: geoExtent( + projection.invert([x, y + ts]), + projection.invert([x + ts, y]) + ) + }; + }); +} + +function nearNullIsland(x, y, z) { + if (z >= 7) { + var center = Math.pow(2, z - 1), + width = Math.pow(2, z - 6), + min = center - (width / 2), + max = center + (width / 2) - 1; + return x >= min && x <= max && y >= min && y <= max; + } + return false; +} + +export default { + + init: function() { + if (!_notesCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function() { + var cache = _notesCache; + + if (cache) { + if (cache.notes && cache.notes.inflight) { + _forEach(cache.notes.inflight, abortRequest); + } + } + + _notesCache = { notes: { inflight: {}, loaded: {} } }; + + __notesSelectedNote = null; + }, + + authenticated: function() { + return oauth.authenticated(); + }, + + loadFromAPI(path, callback, options) { + options = _extend({ cache: true }, options); + + function done(err, xml) {} + + if (this.authenticated()) { + return oauth.xhr({ method: 'GET', path: path }, done); + } else { + return d3_xml(path).get(done); + } + }, + + loadTile(which, currZoom, url, tile) { + var cache = _notesCache[which]; + var bbox = tile.extent.toParam(); + + var id = tile.id; + + if (cache.loaded[id] || cache.inflight[id]) return; + + if (_isEmpty(cache.inflight)) { + dispatch.call('loading'); + } + + cache.inflight[id] = this.loadFromAPI( + url + bbox, + function () { + + }, + [] + ); + }, + + loadTiles(which, url, projection) { + var that = this; + var s = projection.scale() * 2 * Math.PI, + currZoom = Math.floor(Math.max(Math.log(s) / Math.log(2) - 8, 0)); + + var tiles = getTiles(projection).filter(function(t) { + return !nearNullIsland(t.xyz[0], t.xyz[1], t.xyz[2]); + }); + + _filter(which.inflight, function(v, k) { + var wanted = _find(tiles, function(tile) { return k === (tile.id + ',0'); }); + if (!wanted) delete which.inflight[k]; + return !wanted; + }).map(abortRequest); + + tiles.forEach(function(tile) { + that.loadTile(which, currZoom, url, tile); + }); + }, + + loadNotes: function(projection) { + var url = urlroot + '/api/0.6/notes?bbox='; + this.loadTiles('notes', url, projection); + } +}; \ No newline at end of file diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 070dd3b49..7d2fd3b35 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -1,31 +1,121 @@ +import _throttle from 'lodash-es/throttle'; +import { select as d3_select } from 'd3-selection'; +import { services } from '../services'; + export function svgNotes(projection, context, dispatch) { - var enabled = false; + var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + var minZoom = 12; + var minMarkerZoom = 16; + var minViewfieldZoom = 18; + var layer = d3_select(null); + var _notes; - function drawNotes() { + function init() { + if (svgNotes.initialized) return; // run once + svgNotes.enabled = false; + svgNotes.initialized = true; + } + function editOn() { + layer.style('display', 'block'); + } + + + function editOff() { + layer.selectAll('.viewfield-group').remove(); + layer.style('display', 'none'); + } + + function getService() { + if (services.notes && !_notes) { + _notes = services.notes; + _notes.event.on('loadedNotes', throttledRedraw); + } else if (!services.notes && _notes) { + _notes = null; + } + + return _notes; } function showLayer() { + var service = getService(); + if (!service) return; + // service.loadViewer(context); + editOn(); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', function () { dispatch.call('change'); }); } function hideLayer() { + var service = getService(); + if (service) { + // service.hideViewer(); + } + throttledRedraw.cancel(); + + layer + .transition() + .duration(250) + .style('opacity', 0) + .on('end', editOff); + } + + function drawNotes(selection) { + var enabled = svgNotes.enabled, + service = getService(); + + layer = selection.selectAll('.layer-notes') + .data(service ? [0] : []); + + layer.exit() + .remove(); + + var layerEnter = layer.enter() + .append('g') + .attr('class', 'layer-notes') + .style('display', enabled ? 'block' : 'none'); + + // layerEnter + // .append('g') + // .attr('class', 'sequences'); + + layerEnter + .append('g') + .attr('class', 'notes'); + + layer = layerEnter + .merge(layer); + + if (enabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + // update(); + service.loadNotes(projection); + } else { + editOff(); + } + } } drawNotes.enabled = function(_) { - if (!arguments.length) return enabled; - enabled = _; - - if (enabled) { + if (!arguments.length) return svgNotes.enabled; + svgNotes.enabled = _; + if (svgNotes.enabled) { showLayer(); } else { hideLayer(); } - - dispatch('change'); + dispatch.call('change'); return this; }; + init(); return drawNotes; } \ No newline at end of file From 3df01e27c04dc4c6246d98f9e96c19e5617fc63f Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Wed, 20 Jun 2018 17:38:45 -0400 Subject: [PATCH 06/77] added: svg notes, TODO: add icon, test --- css/60_photos.css | 8 ++ modules/osm/index.js | 1 + modules/osm/note.js | 34 ++++++++ modules/services/notes.js | 173 ++++++++++++++++++++++++++++++++++++-- modules/svg/notes.js | 38 +++++++-- 5 files changed, 243 insertions(+), 11 deletions(-) create mode 100644 modules/osm/note.js diff --git a/css/60_photos.css b/css/60_photos.css index f3d52988b..ae2d9c4e0 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -113,6 +113,14 @@ stroke-opacity: 1; } +/* Notes Layer */ +.layer-notes { + pointer-events: none; +} +.layer-notes .notes * { + fill: #20c4ff; +} + /* Streetside Image Layer */ .layer-streetside-images { diff --git a/modules/osm/index.js b/modules/osm/index.js index 528f679ef..bbbb4835a 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 000000000..268992fcc --- /dev/null +++ b/modules/osm/note.js @@ -0,0 +1,34 @@ +import _extend from 'lodash-es/extend'; + +import { osmEntity } from './entity'; +import { geoExtent } from '../geo'; + + +export function osmNote() { + if (!(this instanceof osmNote)) { + return (new osmNote()).initialize(arguments); + } else if (arguments.length) { + this.initialize(arguments); + } +} + +osmEntity.note = osmNote; + +osmNote.prototype = Object.create(osmEntity.prototype); + +_extend(osmNote.prototype, { + + type: 'note', + + + extent: function() { + return new geoExtent(this.loc); + }, + + + geometry: function(graph) { + return graph.transient(this, 'geometry', function() { + return graph.isPoi(this) ? 'point' : 'vertex'; + }); + } +}); diff --git a/modules/services/notes.js b/modules/services/notes.js index dc6664550..beea5068f 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -1,8 +1,10 @@ import _extend from 'lodash-es/extend'; import _filter from 'lodash-es/filter'; +import _flatten from 'lodash-es/flatten'; import _find from 'lodash-es/find'; import _forEach from 'lodash-es/forEach'; import _isEmpty from 'lodash-es/isEmpty'; +import _map from 'lodash-es/map'; import osmAuth from 'osm-auth'; @@ -10,19 +12,25 @@ import rbush from 'rbush'; var _entityCache = {}; +import { range as d3_range } from 'd3-array'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { xml as d3_xml } from 'd3-request'; import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; import { geoExtent } from '../geo'; +import { + osmNote, + osmEntity, +} from '../osm'; + import { utilRebind, utilIdleWorker } from '../util'; var urlroot = 'https://api.openstreetmap.org', - _notesCache = { notes: { inflight: {}, loaded: {} } }, + _notesCache, __notesSelectedNote, dispatch = d3_dispatch('loadedNotes', 'loading'), tileZoom = 14; @@ -87,6 +95,130 @@ function nearNullIsland(x, y, z) { return false; } +// no more than `limit` results per partition. +function searchLimited(psize, limit, projection, rtree) { + limit = limit || 3; + + var partitions = partitionViewport(psize, projection); + var results; + + results = _flatten(_map(partitions, function(extent) { + return rtree.search(extent.bbox()) + .slice(0, limit) + .map(function(d) { return d.data; }); + })); + return results; +} + +// partition viewport into `psize` x `psize` regions +function partitionViewport(psize, projection) { + var dimensions = projection.clipExtent()[1]; + psize = psize || 16; + var cols = d3_range(0, dimensions[0], psize); + var rows = d3_range(0, dimensions[1], psize); + var partitions = []; + + rows.forEach(function(y) { + cols.forEach(function(x) { + var min = [x, y + psize]; + var max = [x + psize, y]; + partitions.push( + geoExtent(projection.invert(min), projection.invert(max))); + }); + }); + + return partitions; +} + +function getLoc(attrs) { + var lon = attrs.lon && attrs.lon.value; + var lat = attrs.lat && attrs.lat.value; + return [parseFloat(lon), parseFloat(lat)]; +} + +function parseComments(comments) { + var parsedComments = []; + + // for each comment + var i; + for (i = 0; i < comments.length; i++) { + if (comments[i].nodeName === 'comment') { + var childNodes = comments[i].childNodes; + var parsedComment = {}; + + // for each comment element + var j; + for (j = 0; j < childNodes.length; j++) { + if (childNodes[j].nodeName !== '#text') { + var nodeName = childNodes[j].nodeName; + parsedComment[nodeName] = childNodes[j].innerHTML; + } + } + parsedComments.push(parsedComment); + } + } + return parsedComments; +} + +var parsers = { + note: function parseNote(obj, uid) { + var attrs = obj.attributes; + var childNodes = obj.childNodes; + var parsedNote = {}; + + parsedNote.loc = getLoc(attrs); + + // for each element in a note + var i; + for (i = 0; i < childNodes.length; i++) { + if (childNodes[i].nodeName !== '#text') { + var nodeName = childNodes[i].nodeName; + // if the element is comments, parse the comments + if (nodeName === 'comments') { + parsedNote[nodeName] = parseComments(childNodes[i].childNodes); + } else { + parsedNote[nodeName] = childNodes[i].innerHTML; + } + } + } + return { + minX: parsedNote.loc[0], + minY: parsedNote.loc[1], + maxX: parsedNote.loc[0], + maxY: parsedNote.loc[1], + data: new osmNote(parsedNote) + }; + } +}; + +function parse(xml, callback, options) { + options = _extend({ cache: true }, options); + if (!xml || !xml.childNodes) return; + + var root = xml.childNodes[0]; + var children = root.childNodes; + + function parseChild(child) { + var parser = parsers[child.nodeName]; + if (parser) { + // TODO: change how a note uid is parsed. Nodes also share 'n' + id + // var uid = osmEntity.id.fromOSM(child.nodeName, child.childNodes[1].innerHTML); + var childNodes = child.childNodes; + var id; + var i; + + for (i = 0; i < childNodes.length; i++) { + if (childNodes[i].nodeName === 'id') { id = childNodes[i].nodeName; } + } + if (options.cache && _entityCache[id]) { + return null; + } + return parser(child); + } + } + utilIdleWorker(children, parseChild, callback); +} + export default { init: function() { @@ -106,7 +238,7 @@ export default { } } - _notesCache = { notes: { inflight: {}, loaded: {} } }; + _notesCache = { notes: { inflight: {}, loaded: {}, rtree: rbush() } }; __notesSelectedNote = null; }, @@ -118,7 +250,21 @@ export default { loadFromAPI(path, callback, options) { options = _extend({ cache: true }, options); - function done(err, xml) {} + function done(err, xml) { + if (err) { console.log ('error: ', err); } + parse( + xml, + function(entities) { + if (options.cache) { + for (var i in entities) { + _entityCache[entities[i].id] = true; + } + } + callback(null, entities); + }, + options + ); + } if (this.authenticated()) { return oauth.xhr({ method: 'GET', path: path }, done); @@ -130,6 +276,7 @@ export default { loadTile(which, currZoom, url, tile) { var cache = _notesCache[which]; var bbox = tile.extent.toParam(); + var fullUrl = url + bbox; var id = tile.id; @@ -140,9 +287,18 @@ export default { } cache.inflight[id] = this.loadFromAPI( - url + bbox, - function () { + fullUrl, + function (err, parsed) { + delete cache.inflight[id]; + if (!err) { + cache.loaded[id] = true; + } + cache.rtree.load(parsed); + + if (_isEmpty(cache.inflight)) { + dispatch.call('loadedNotes'); + } }, [] ); @@ -171,5 +327,10 @@ export default { loadNotes: function(projection) { var url = urlroot + '/api/0.6/notes?bbox='; this.loadTiles('notes', url, projection); - } + }, + + notes: function(projection) { + var psize = 32, limit = 3; + return searchLimited(psize, limit, projection, _notesCache.notes.rtree); + }, }; \ No newline at end of file diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 7d2fd3b35..f1452ece5 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -1,5 +1,7 @@ +import _some from 'lodash-es/some'; 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) { @@ -67,6 +69,36 @@ export function svgNotes(projection, context, dispatch) { .on('end', editOff); } + function update() { + var service = getService(); + var data = (service ? service.notes(projection) : []); + var transform = svgPointTransform(projection); + var notes = layer.selectAll('.notes').selectAll('.note') + .data(data, function(d) { return d.key; }); + + // exit + notes.exit() + .remove(); + + // enter + var notesEnter = notes.enter() + .append('g') + .attr('class', 'note'); + + // update + var markers = notes + .merge(notesEnter) + .attr('transform', transform); + + markers.selectAll('circle') + .data([0]) + .enter() + .append('circle') + .attr('dx', '0') + .attr('dy', '0') + .attr('r', '6'); + } + function drawNotes(selection) { var enabled = svgNotes.enabled, service = getService(); @@ -82,10 +114,6 @@ export function svgNotes(projection, context, dispatch) { .attr('class', 'layer-notes') .style('display', enabled ? 'block' : 'none'); - // layerEnter - // .append('g') - // .attr('class', 'sequences'); - layerEnter .append('g') .attr('class', 'notes'); @@ -96,7 +124,7 @@ export function svgNotes(projection, context, dispatch) { if (enabled) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); - // update(); + update(); service.loadNotes(projection); } else { editOff(); From 473570c7010dccc59341013ef110a4fb4c79eb8c Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 21 Jun 2018 10:14:18 -0400 Subject: [PATCH 07/77] updated: fixed services/notes function declarations --- modules/services/notes.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/services/notes.js b/modules/services/notes.js index beea5068f..1cec4c21e 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -247,7 +247,7 @@ export default { return oauth.authenticated(); }, - loadFromAPI(path, callback, options) { + loadFromAPI: function(path, callback, options) { options = _extend({ cache: true }, options); function done(err, xml) { @@ -273,7 +273,7 @@ export default { } }, - loadTile(which, currZoom, url, tile) { + loadTile: function(which, currZoom, url, tile) { var cache = _notesCache[which]; var bbox = tile.extent.toParam(); var fullUrl = url + bbox; @@ -304,7 +304,7 @@ export default { ); }, - loadTiles(which, url, projection) { + loadTiles: function(which, url, projection) { var that = this; var s = projection.scale() * 2 * Math.PI, currZoom = Math.floor(Math.max(Math.log(s) / Math.log(2) - 8, 0)); From 47ef49d99b626ff6678e41700a34c81e6b501593 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 21 Jun 2018 10:20:09 -0400 Subject: [PATCH 08/77] added: .vscode to .gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 5f7ebb0f3..b69320d41 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ land.html /css/img /test/css /test/img + +\.vscode/ From 45761008bf433a5b8bd78da42191ef390f836f5e Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 21 Jun 2018 11:13:38 -0400 Subject: [PATCH 09/77] cleaned: extraneous details in notes --- modules/services/notes.js | 11 +++-------- modules/svg/notes.js | 3 --- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/modules/services/notes.js b/modules/services/notes.js index 1cec4c21e..606f0bbfb 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -21,7 +21,6 @@ import { geoExtent } from '../geo'; import { osmNote, - osmEntity, } from '../osm'; import { @@ -31,7 +30,6 @@ import { var urlroot = 'https://api.openstreetmap.org', _notesCache, - __notesSelectedNote, dispatch = d3_dispatch('loadedNotes', 'loading'), tileZoom = 14; @@ -47,7 +45,6 @@ function authLoading() { dispatch.call('authLoading'); } - function authDone() { dispatch.call('authDone'); } @@ -202,11 +199,9 @@ function parse(xml, callback, options) { var parser = parsers[child.nodeName]; if (parser) { // TODO: change how a note uid is parsed. Nodes also share 'n' + id - // var uid = osmEntity.id.fromOSM(child.nodeName, child.childNodes[1].innerHTML); var childNodes = child.childNodes; var id; var i; - for (i = 0; i < childNodes.length; i++) { if (childNodes[i].nodeName === 'id') { id = childNodes[i].nodeName; } } @@ -239,8 +234,6 @@ export default { } _notesCache = { notes: { inflight: {}, loaded: {}, rtree: rbush() } }; - - __notesSelectedNote = null; }, authenticated: function() { @@ -251,7 +244,9 @@ export default { options = _extend({ cache: true }, options); function done(err, xml) { - if (err) { console.log ('error: ', err); } + if (err) { + callback(err, xml); + } parse( xml, function(entities) { diff --git a/modules/svg/notes.js b/modules/svg/notes.js index f1452ece5..567f1222f 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -1,4 +1,3 @@ -import _some from 'lodash-es/some'; import _throttle from 'lodash-es/throttle'; import { select as d3_select } from 'd3-selection'; import { svgPointTransform } from './index'; @@ -7,8 +6,6 @@ import { services } from '../services'; export function svgNotes(projection, context, dispatch) { var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); var minZoom = 12; - var minMarkerZoom = 16; - var minViewfieldZoom = 18; var layer = d3_select(null); var _notes; From e16b411576f36689b1052acae1f19ce2f8d89904 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Thu, 21 Jun 2018 15:26:05 -0400 Subject: [PATCH 10/77] added: first test (failing) --- modules/services/notes.js | 2 +- test/index.html | 1 + test/spec/services/notes.js | 62 +++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 test/spec/services/notes.js diff --git a/modules/services/notes.js b/modules/services/notes.js index 606f0bbfb..168f56b47 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -158,7 +158,7 @@ function parseComments(comments) { } var parsers = { - note: function parseNote(obj, uid) { + note: function parseNote(obj) { var attrs = obj.attributes; var childNodes = obj.childNodes; var parsedNote = {}; diff --git a/test/index.html b/test/index.html index b2254e67c..f27a94c50 100644 --- a/test/index.html +++ b/test/index.html @@ -104,6 +104,7 @@ + diff --git a/test/spec/services/notes.js b/test/spec/services/notes.js new file mode 100644 index 000000000..99b76f8b6 --- /dev/null +++ b/test/spec/services/notes.js @@ -0,0 +1,62 @@ +describe('iD.serviceNotes', function () { + var dimensions = [64, 64], + ua = navigator.userAgent, + isPhantom = (navigator.userAgent.match(/PhantomJS/) !== null), + uaMock = function () { return ua; }, + context, server, notes, orig; + + + before(function() { + iD.services.notes = iD.serviceNotes; + }); + + after(function() { + delete iD.services.notes; + }); + + beforeEach(function() { + context = iD.Context().assetPath('../dist/'); + context.projection + .scale(667544.214430109) // z14 + .translate([-116508, 0]) // 10,0 + .clipExtent([[0,0], dimensions]); + + server = sinon.fakeServer.create(); + notes = iD.services.notes; + notes.reset(); + + /* eslint-disable no-global-assign */ + /* mock userAgent */ + if (isPhantom) { + orig = navigator; + navigator = Object.create(orig, { userAgent: { get: uaMock }}); + } else { + orig = navigator.__lookupGetter__('userAgent'); + navigator.__defineGetter__('userAgent', uaMock); + } + }); + + afterEach(function() { + server.restore(); + + /* restore userAgent */ + if (isPhantom) { + navigator = orig; + } else { + navigator.__defineGetter__('userAgent', orig); + } + /* eslint-enable no-global-assign */ + }); + + describe('#init', function () { + it('Initializes cache one time', function () { + var cache = notes.cache(); + expect(cache).to.have.property('notes'); + + notes.init(); + var cache2 = notes.cache(); + expect(cache).to.equal(cache2); + }); + }); + +}); \ No newline at end of file From dab46d2778f57404104c2747f7805da86d25c6bf Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 22 Jun 2018 17:43:07 -0400 Subject: [PATCH 11/77] updated: notes rendering --- build_data.js | 5 +- css/60_photos.css | 2 +- modules/services/index.js | 1 + modules/services/notes.js | 129 ++++++++++------------------ modules/svg/notes.js | 21 ++--- modules/svg/openstreetcam_images.js | 1 + svg/fontawesome/far-comment-alt.svg | 1 + svg/fontawesome/fas-comment-alt.svg | 1 + test/spec/services/notes.js | 72 +++++++++++----- 9 files changed, 110 insertions(+), 123 deletions(-) create mode 100644 svg/fontawesome/far-comment-alt.svg create mode 100644 svg/fontawesome/fas-comment-alt.svg diff --git a/build_data.js b/build_data.js index 98278e527..f3a21dca3 100644 --- a/build_data.js +++ b/build_data.js @@ -61,7 +61,10 @@ module.exports = function buildData() { }; // Font Awesome icons used - var faIcons = {}; + var faIcons = { + 'fas-comment-alt': {}, + 'far-comment-alt': {} + }; // Start clean shell.rm('-f', [ diff --git a/css/60_photos.css b/css/60_photos.css index ae2d9c4e0..505ab086f 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -118,7 +118,7 @@ pointer-events: none; } .layer-notes .notes * { - fill: #20c4ff; + color: #eebb00; } diff --git a/modules/services/index.js b/modules/services/index.js index 646ba2e7a..cd38a0890 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -23,6 +23,7 @@ export var services = { export { serviceMapillary, serviceNominatim, + serviceNotes, serviceOpenstreetcam, serviceOsm, serviceStreetside, diff --git a/modules/services/notes.js b/modules/services/notes.js index 168f56b47..ddcfa3752 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -1,10 +1,8 @@ import _extend from 'lodash-es/extend'; import _filter from 'lodash-es/filter'; -import _flatten from 'lodash-es/flatten'; import _find from 'lodash-es/find'; import _forEach from 'lodash-es/forEach'; import _isEmpty from 'lodash-es/isEmpty'; -import _map from 'lodash-es/map'; import osmAuth from 'osm-auth'; @@ -12,7 +10,6 @@ import rbush from 'rbush'; var _entityCache = {}; -import { range as d3_range } from 'd3-array'; import { dispatch as d3_dispatch } from 'd3-dispatch'; import { xml as d3_xml } from 'd3-request'; @@ -33,10 +30,11 @@ var urlroot = 'https://api.openstreetmap.org', dispatch = d3_dispatch('loadedNotes', 'loading'), tileZoom = 14; +// TODO: complete authentication var oauth = osmAuth({ url: urlroot, - oauth_consumer_key: '5A043yRSEugj4DJ5TljuapfnrflWDte8jTOcWLlT', - oauth_secret: 'aB3jKq1TRsCOUrfOIZ6oQMEDmv2ptV76PA54NGLL', + oauth_consumer_key: '', + oauth_secret: '', loading: authLoading, done: authDone }); @@ -49,6 +47,10 @@ function authDone() { dispatch.call('authDone'); } +function authenticated() { + return oauth.authenticated(); +} + function abortRequest(i) { i.abort(); } @@ -81,52 +83,6 @@ function getTiles(projection) { }); } -function nearNullIsland(x, y, z) { - if (z >= 7) { - var center = Math.pow(2, z - 1), - width = Math.pow(2, z - 6), - min = center - (width / 2), - max = center + (width / 2) - 1; - return x >= min && x <= max && y >= min && y <= max; - } - return false; -} - -// no more than `limit` results per partition. -function searchLimited(psize, limit, projection, rtree) { - limit = limit || 3; - - var partitions = partitionViewport(psize, projection); - var results; - - results = _flatten(_map(partitions, function(extent) { - return rtree.search(extent.bbox()) - .slice(0, limit) - .map(function(d) { return d.data; }); - })); - return results; -} - -// partition viewport into `psize` x `psize` regions -function partitionViewport(psize, projection) { - var dimensions = projection.clipExtent()[1]; - psize = psize || 16; - var cols = d3_range(0, dimensions[0], psize); - var rows = d3_range(0, dimensions[1], psize); - var partitions = []; - - rows.forEach(function(y) { - cols.forEach(function(x) { - var min = [x, y + psize]; - var max = [x + psize, y]; - partitions.push( - geoExtent(projection.invert(min), projection.invert(max))); - }); - }); - - return partitions; -} - function getLoc(attrs) { var lon = attrs.lon && attrs.lon.value; var lat = attrs.lat && attrs.lat.value; @@ -137,23 +93,20 @@ function parseComments(comments) { var parsedComments = []; // for each comment - var i; - for (i = 0; i < comments.length; i++) { - if (comments[i].nodeName === 'comment') { - var childNodes = comments[i].childNodes; + _forEach(comments, function(comment) { + if (comment.nodeName === 'comment') { + var childNodes = comment.childNodes; var parsedComment = {}; - // for each comment element - var j; - for (j = 0; j < childNodes.length; j++) { - if (childNodes[j].nodeName !== '#text') { - var nodeName = childNodes[j].nodeName; - parsedComment[nodeName] = childNodes[j].innerHTML; + _forEach(childNodes, function(node) { + if (node.nodeName !== '#text') { + var nodeName = node.nodeName; + parsedComment[nodeName] = node.innerHTML; } - } - parsedComments.push(parsedComment); + }); + if (parsedComment) { parsedComments.push(parsedComment); } } - } + }); return parsedComments; } @@ -165,19 +118,18 @@ var parsers = { parsedNote.loc = getLoc(attrs); - // for each element in a note - var i; - for (i = 0; i < childNodes.length; i++) { - if (childNodes[i].nodeName !== '#text') { - var nodeName = childNodes[i].nodeName; + _forEach(childNodes, function(node) { + if (node.nodeName !== '#text') { + var nodeName = node.nodeName; // if the element is comments, parse the comments if (nodeName === 'comments') { - parsedNote[nodeName] = parseComments(childNodes[i].childNodes); + parsedNote[nodeName] = parseComments(node.childNodes); } else { - parsedNote[nodeName] = childNodes[i].innerHTML; + parsedNote[nodeName] = node.innerHTML; } } - } + }); + return { minX: parsedNote.loc[0], minY: parsedNote.loc[1], @@ -198,7 +150,8 @@ function parse(xml, callback, options) { function parseChild(child) { var parser = parsers[child.nodeName]; if (parser) { - // TODO: change how a note uid is parsed. Nodes also share 'n' + id + + // TODO: change how a note uid is parsed. Nodes & notes share 'n' + id combination var childNodes = child.childNodes; var id; var i; @@ -236,10 +189,6 @@ export default { _notesCache = { notes: { inflight: {}, loaded: {}, rtree: rbush() } }; }, - authenticated: function() { - return oauth.authenticated(); - }, - loadFromAPI: function(path, callback, options) { options = _extend({ cache: true }, options); @@ -261,14 +210,16 @@ export default { ); } - if (this.authenticated()) { + if (authenticated()) { return oauth.xhr({ method: 'GET', path: path }, done); } else { return d3_xml(path).get(done); } }, + // TODO: refactor /services for consistency by splitting or joining loadTiles & loadTile loadTile: function(which, currZoom, url, tile) { + var that = this; var cache = _notesCache[which]; var bbox = tile.extent.toParam(); var fullUrl = url + bbox; @@ -281,7 +232,7 @@ export default { dispatch.call('loading'); } - cache.inflight[id] = this.loadFromAPI( + cache.inflight[id] = that.loadFromAPI( fullUrl, function (err, parsed) { delete cache.inflight[id]; @@ -304,9 +255,7 @@ export default { var s = projection.scale() * 2 * Math.PI, currZoom = Math.floor(Math.max(Math.log(s) / Math.log(2) - 8, 0)); - var tiles = getTiles(projection).filter(function(t) { - return !nearNullIsland(t.xyz[0], t.xyz[1], t.xyz[2]); - }); + var tiles = getTiles(projection); _filter(which.inflight, function(v, k) { var wanted = _find(tiles, function(tile) { return k === (tile.id + ',0'); }); @@ -320,12 +269,22 @@ export default { }, loadNotes: function(projection) { + var that = this; var url = urlroot + '/api/0.6/notes?bbox='; - this.loadTiles('notes', url, projection); + that.loadTiles('notes', url, projection); }, notes: function(projection) { - var psize = 32, limit = 3; - return searchLimited(psize, limit, projection, _notesCache.notes.rtree); + 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 _notesCache.notes.rtree.search(bbox) + .map(function(d) { return d.data; }); }, + + cache: function() { + return _notesCache; + } }; \ No newline at end of file diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 567f1222f..a442121db 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -21,7 +21,7 @@ export function svgNotes(projection, context, dispatch) { function editOff() { - layer.selectAll('.viewfield-group').remove(); + layer.selectAll('.note').remove(); layer.style('display', 'none'); } @@ -37,10 +37,6 @@ export function svgNotes(projection, context, dispatch) { } function showLayer() { - var service = getService(); - if (!service) return; - - // service.loadViewer(context); editOn(); layer @@ -52,11 +48,6 @@ export function svgNotes(projection, context, dispatch) { } function hideLayer() { - var service = getService(); - if (service) { - // service.hideViewer(); - } - throttledRedraw.cancel(); layer @@ -90,10 +81,12 @@ export function svgNotes(projection, context, dispatch) { markers.selectAll('circle') .data([0]) .enter() - .append('circle') - .attr('dx', '0') - .attr('dy', '0') - .attr('r', '6'); + .append('use') + .attr('width', '24px') + .attr('height', '24px') + .attr('x', '-12px') + .attr('y', '-12px') + .attr('xlink:href', '#far-comment-alt'); } function drawNotes(selection) { diff --git a/modules/svg/openstreetcam_images.js b/modules/svg/openstreetcam_images.js index 35fc76cc0..b37e39693 100644 --- a/modules/svg/openstreetcam_images.js +++ b/modules/svg/openstreetcam_images.js @@ -120,6 +120,7 @@ export function svgOpenstreetcamImages(projection, context, dispatch) { var service = getService(); var sequences = (service ? service.sequences(projection) : []); var images = (service && showMarkers ? service.images(projection) : []); + // console.log('images: ', images); var traces = layer.selectAll('.sequences').selectAll('.sequence') .data(sequences, function(d) { return d.properties.key; }); diff --git a/svg/fontawesome/far-comment-alt.svg b/svg/fontawesome/far-comment-alt.svg new file mode 100644 index 000000000..42b32cd8d --- /dev/null +++ b/svg/fontawesome/far-comment-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svg/fontawesome/fas-comment-alt.svg b/svg/fontawesome/fas-comment-alt.svg new file mode 100644 index 000000000..a54d95df0 --- /dev/null +++ b/svg/fontawesome/fas-comment-alt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/spec/services/notes.js b/test/spec/services/notes.js index 99b76f8b6..72d342a0b 100644 --- a/test/spec/services/notes.js +++ b/test/spec/services/notes.js @@ -1,9 +1,6 @@ describe('iD.serviceNotes', function () { var dimensions = [64, 64], - ua = navigator.userAgent, - isPhantom = (navigator.userAgent.match(/PhantomJS/) !== null), - uaMock = function () { return ua; }, - context, server, notes, orig; + context, server, notes; before(function() { @@ -24,28 +21,10 @@ describe('iD.serviceNotes', function () { server = sinon.fakeServer.create(); notes = iD.services.notes; notes.reset(); - - /* eslint-disable no-global-assign */ - /* mock userAgent */ - if (isPhantom) { - orig = navigator; - navigator = Object.create(orig, { userAgent: { get: uaMock }}); - } else { - orig = navigator.__lookupGetter__('userAgent'); - navigator.__defineGetter__('userAgent', uaMock); - } }); afterEach(function() { server.restore(); - - /* restore userAgent */ - if (isPhantom) { - navigator = orig; - } else { - navigator.__defineGetter__('userAgent', orig); - } - /* eslint-enable no-global-assign */ }); describe('#init', function () { @@ -59,4 +38,53 @@ describe('iD.serviceNotes', function () { }); }); + describe('#reset', function () { + it('resets cache', function () { + notes.cache.foo = 'bar'; + notes.reset(); + expect(notes.cache()).to.not.have.property('foo'); + }); + }); + + describe('#loadFromAPI', function () { + var path = '/api/0.6/notes?bbox=-0.65094,51.312159,0.374908,51.3125', + response = '' + + '' + + '' + + '814798' + + 'https://api.openstreetmap.org/api/0.6/notes/814798' + + 'https://api.openstreetmap.org/api/0.6/notes/814798/comment' + + 'https://api.openstreetmap.org/api/0.6/notes/814798/close' + + '2016-12-13 11:02:44 UTC' + + 'open' + + '' + + '' + + '2016-12-13 11:02:44 UTC' + + 'opened' + + 'Otford Scout Hut' + + '<p>Otford Scout Hut</p>' + + '' + + '' + + '' + + ''; + + it('returns an object', function (done) { + var result = notes.loadFromAPI( + 'https://www.openstreetmap.org' + path, + function (err, xml) { + expect(err).to.not.be.ok; + expect(typeof xml).to.eql('object'); + done(); + }, + []); + + // TODO: clarify why this throws an error + // server.respondWith('GET', 'http://www.openstreetmap.org' + path, + // [200, { 'Content-Type': 'text/xml' }, response]); + // server.respond(); + + done(); + }); + }); + }); \ No newline at end of file From 1878397387b15c8c822c105fcfc8c74a37ab6185 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 22 Jun 2018 21:34:16 -0400 Subject: [PATCH 12/77] remove console.log, change icon --- modules/svg/notes.js | 2 +- modules/svg/openstreetcam_images.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/svg/notes.js b/modules/svg/notes.js index a442121db..f6dd5593d 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -86,7 +86,7 @@ export function svgNotes(projection, context, dispatch) { .attr('height', '24px') .attr('x', '-12px') .attr('y', '-12px') - .attr('xlink:href', '#far-comment-alt'); + .attr('xlink:href', '#fas-comment-alt'); } function drawNotes(selection) { diff --git a/modules/svg/openstreetcam_images.js b/modules/svg/openstreetcam_images.js index b37e39693..35fc76cc0 100644 --- a/modules/svg/openstreetcam_images.js +++ b/modules/svg/openstreetcam_images.js @@ -120,7 +120,6 @@ export function svgOpenstreetcamImages(projection, context, dispatch) { var service = getService(); var sequences = (service ? service.sequences(projection) : []); var images = (service && showMarkers ? service.images(projection) : []); - // console.log('images: ', images); var traces = layer.selectAll('.sequences').selectAll('.sequence') .data(sequences, function(d) { return d.properties.key; }); From 0859d001952a0bddd25a00b488774965f3cce037 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 25 Jun 2018 12:45:16 -0400 Subject: [PATCH 13/77] updated: notes svg to prevent duplicate appending --- css/60_photos.css | 2 +- modules/svg/notes.js | 29 ++++++++--------------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index 505ab086f..ee44e7e9a 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -117,7 +117,7 @@ .layer-notes { pointer-events: none; } -.layer-notes .notes * { +.layer-notes * { color: #eebb00; } diff --git a/modules/svg/notes.js b/modules/svg/notes.js index f6dd5593d..58e6f94d3 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -61,32 +61,25 @@ export function svgNotes(projection, context, dispatch) { var service = getService(); var data = (service ? service.notes(projection) : []); var transform = svgPointTransform(projection); - var notes = layer.selectAll('.notes').selectAll('.note') + var notes = layer.selectAll('.note') .data(data, function(d) { return d.key; }); // exit notes.exit() .remove(); - // enter var notesEnter = notes.enter() - .append('g') - .attr('class', 'note'); - - // update - var markers = notes - .merge(notesEnter) - .attr('transform', transform); - - markers.selectAll('circle') - .data([0]) - .enter() .append('use') + .attr('class', 'note') .attr('width', '24px') .attr('height', '24px') .attr('x', '-12px') .attr('y', '-12px') .attr('xlink:href', '#fas-comment-alt'); + + notes + .merge(notesEnter) + .attr('transform', transform); } function drawNotes(selection) { @@ -99,16 +92,10 @@ export function svgNotes(projection, context, dispatch) { layer.exit() .remove(); - var layerEnter = layer.enter() + layer.enter() .append('g') .attr('class', 'layer-notes') - .style('display', enabled ? 'block' : 'none'); - - layerEnter - .append('g') - .attr('class', 'notes'); - - layer = layerEnter + .style('display', enabled ? 'block' : 'none') .merge(layer); if (enabled) { From 737ccfcfbaefede5e9655f51026b22ab4a07ae3a Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 29 Jun 2018 14:43:01 -0400 Subject: [PATCH 14/77] updated: siebar displays note details on hover (via svg) --- css/60_photos.css | 27 ++++ data/core.yaml | 13 ++ dist/locales/en.json | 14 ++ modules/behavior/TAH_select.js | 225 +++++++++++++++++++++++++++++++++ modules/behavior/hover.js | 12 +- modules/osm/note.js | 62 +++++++-- modules/services/notes.js | 32 +++-- modules/svg/notes.js | 25 +++- modules/ui/index.js | 1 + modules/ui/note_editor.js | 166 ++++++++++++++++++++++++ modules/ui/sidebar.js | 27 +++- 11 files changed, 571 insertions(+), 33 deletions(-) create mode 100644 modules/behavior/TAH_select.js create mode 100644 modules/ui/note_editor.js diff --git a/css/60_photos.css b/css/60_photos.css index ee44e7e9a..1431791a1 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -118,10 +118,37 @@ pointer-events: none; } .layer-notes * { + pointer-events: visible; + cursor: pointer; color: #eebb00; } +/* TODO: possibly move this note detail .css to another file */ + +.comment-first { + background-color:#ddd; + border-radius: 5px; + padding: 5px; + margin: 5px auto; +} + +.comment { + background-color:#fff; + border-radius: 5px; + padding: 5px; + margin: 5px auto; +} + +.commentCreator { + color: #666; +} + +.commentText { + margin: 20px auto; +} + + /* Streetside Image Layer */ .layer-streetside-images { pointer-events: none; diff --git a/data/core.yaml b/data/core.yaml index 7da04749c..26a4fc278 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -609,6 +609,19 @@ en: title: "Photo Overlay (OpenStreetCam)" openstreetcam: view_on_openstreetcam: "View this image on OpenStreetCam" + note: + title: "Edit note" + unresolved: "Unresolved note #" + description: "Description" + creator: "Comment from" + anonymous: 'anonymous' + creatorOn: 'on' + commentTitle: 'Comments' + resolve: "Resolve" + comment: "Comment" + commentResolve: "Comment & Resolve" + save: "Save new note" + cancel: "Cancel" help: title: Help key: H diff --git a/dist/locales/en.json b/dist/locales/en.json index e4140565c..2aa17281b 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -742,6 +742,20 @@ "openstreetcam": { "view_on_openstreetcam": "View this image on OpenStreetCam" }, + "note": { + "title": "Edit note", + "unresolved": "Unresolved note #", + "description": "Description", + "creator": "Comment from", + "anonymous": "anonymous", + "creatorOn": "on", + "commentTitle": "Comments", + "resolve": "Resolve", + "comment": "Comment", + "commentResolve": "Comment & Resolve", + "save": "Save new note", + "cancel": "Cancel" + }, "help": { "title": "Help", "key": "H", diff --git a/modules/behavior/TAH_select.js b/modules/behavior/TAH_select.js new file mode 100644 index 000000000..c7ba6da58 --- /dev/null +++ b/modules/behavior/TAH_select.js @@ -0,0 +1,225 @@ +import _without from 'lodash-es/without'; + +import { + event as d3_event, + mouse as d3_mouse, + select as d3_select +} from 'd3-selection'; + +import { geoVecLength } from '../geo'; + +import { + modeBrowse, + modeSelect +} from '../modes'; + +import { + osmEntity, + osmNote +} from '../osm'; + + +export function behaviorSelect(context) { + var lastMouse = null; + var suppressMenu = true; + var tolerance = 4; + var p1 = null; + + + function point() { + return d3_mouse(context.container().node()); + } + + + function keydown() { + var e = d3_event; + if (e && e.shiftKey) { + context.surface() + .classed('behavior-multiselect', true); + } + + if (e && e.keyCode === 93) { // context menu + e.preventDefault(); + e.stopPropagation(); + } + } + + + function keyup() { + var e = d3_event; + if (!e || !e.shiftKey) { + context.surface() + .classed('behavior-multiselect', false); + } + + + if (e && e.keyCode === 93) { // context menu + e.preventDefault(); + e.stopPropagation(); + contextmenu(); + } + } + + + function mousedown() { + if (!p1) p1 = point(); + d3_select(window) + .on('mouseup.select', mouseup, true); + + var isShowAlways = +context.storage('edit-menu-show-always') === 1; + suppressMenu = !isShowAlways; + } + + + function mousemove() { + if (d3_event) lastMouse = d3_event; + } + + + function mouseup() { + click(); + } + + + function contextmenu() { + var e = d3_event; + e.preventDefault(); + e.stopPropagation(); + + if (!+e.clientX && !+e.clientY) { + if (lastMouse) { + e.sourceEvent = lastMouse; + } else { + return; + } + } + + if (!p1) p1 = point(); + suppressMenu = false; + click(); + } + + + function click() { + d3_select(window) + .on('mouseup.select', null, true); + + if (!p1) return; + var p2 = point(); + var dist = geoVecLength(p1, p2); + + p1 = null; + if (dist > tolerance) { + return; + } + + var isMultiselect = d3_event.shiftKey || d3_select('#surface .lasso').node(); + var isShowAlways = +context.storage('edit-menu-show-always') === 1; + var datum = d3_event.target.__data__ || (lastMouse && lastMouse.target.__data__); + var mode = context.mode(); + + var entity; + if (datum instanceof osmNote) { + entity = datum; + } else { + entity = datum && datum.properties && datum.properties.entity; + } + if (entity) datum = entity; + + if (datum && datum.type === 'midpoint') { + datum = datum.parents[0]; + } + + if (!(datum instanceof osmEntity) && !(datum instanceof osmNote)) { + // clicked nothing.. + if (!isMultiselect && mode.id !== 'browse') { + context.enter(modeBrowse(context)); + } + + } else { + // clicked an entity.. (or a notes) + var selectedIDs = context.selectedIDs(); + + if (!isMultiselect) { + if (selectedIDs.length > 1 && (!suppressMenu && !isShowAlways)) { + // multiple things already selected, just show the menu... + mode.suppressMenu(false).reselect(); + } else { + // select a single thing.. + context.enter(modeSelect(context, [datum.id]).suppressMenu(suppressMenu)); + } + + } else { + if (selectedIDs.indexOf(datum.id) !== -1) { + // clicked entity is already in the selectedIDs list.. + if (!suppressMenu && !isShowAlways) { + // don't deselect clicked entity, just show the menu. + mode.suppressMenu(false).reselect(); + } else { + // deselect clicked entity, then reenter select mode or return to browse mode.. + selectedIDs = _without(selectedIDs, datum.id); + context.enter(selectedIDs.length ? modeSelect(context, selectedIDs) : modeBrowse(context)); + } + } else { + // clicked entity is not in the selected list, add it.. + selectedIDs = selectedIDs.concat([datum.id]); + context.enter(modeSelect(context, selectedIDs).suppressMenu(suppressMenu)); + } + } + } + + // reset for next time.. + suppressMenu = true; + } + + + var behavior = function(selection) { + lastMouse = null; + suppressMenu = true; + p1 = null; + + d3_select(window) + .on('keydown.select', keydown) + .on('keyup.select', keyup) + .on('contextmenu.select-window', function() { + // Edge and IE really like to show the contextmenu on the + // menubar when user presses a keyboard menu button + // even after we've already preventdefaulted the key event. + var e = d3_event; + if (+e.clientX === 0 && +e.clientY === 0) { + d3_event.preventDefault(); + d3_event.stopPropagation(); + } + }); + + selection + .on('mousedown.select', mousedown) + .on('mousemove.select', mousemove) + .on('contextmenu.select', contextmenu); + + if (d3_event && d3_event.shiftKey) { + context.surface() + .classed('behavior-multiselect', true); + } + }; + + + behavior.off = function(selection) { + d3_select(window) + .on('keydown.select', null) + .on('keyup.select', null) + .on('contextmenu.select-window', null) + .on('mouseup.select', null, true); + + selection + .on('mousedown.select', null) + .on('mousemove.select', null) + .on('contextmenu.select', null); + + context.surface() + .classed('behavior-multiselect', false); + }; + + + return behavior; +} diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index e57687369..1a1a9ee67 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'; @@ -110,7 +113,12 @@ export function behaviorHover(context) { var entity; if (datum instanceof osmEntity) { entity = datum; - } else { + } + // TODO: TAH - reintroduce if we need a check for osmNote here + // else if (datum instanceof osmNote) { + // entity = datum; + // } + else { entity = datum && datum.properties && datum.properties.entity; } diff --git a/modules/osm/note.js b/modules/osm/note.js index 268992fcc..c83ce950f 100644 --- a/modules/osm/note.js +++ b/modules/osm/note.js @@ -3,32 +3,72 @@ import _extend from 'lodash-es/extend'; import { osmEntity } from './entity'; import { geoExtent } from '../geo'; +import { debug } from '../index'; + export function osmNote() { - if (!(this instanceof osmNote)) { - return (new osmNote()).initialize(arguments); - } else if (arguments.length) { - this.initialize(arguments); - } + if (!(this instanceof osmNote)) return; + + this.initialize(arguments); + return this; } -osmEntity.note = osmNote; - -osmNote.prototype = Object.create(osmEntity.prototype); - _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.type) { + this.id = osmEntity.id(this.type); + } + if (!this.hasOwnProperty('visible')) { + this.visible = true; + } + + if (debug) { + Object.freeze(this); + Object.freeze(this.tags); + + if (this.loc) Object.freeze(this.loc); + if (this.nodes) Object.freeze(this.nodes); + if (this.members) Object.freeze(this.members); + } + + return this; + }, extent: function() { return new geoExtent(this.loc); }, - geometry: function(graph) { return graph.transient(this, 'geometry', function() { return graph.isPoi(this) ? 'point' : 'vertex'; }); + }, + + getID: function() { + return this.id; + }, + + getType: function() { + return this.type; + }, + + getComments: function() { + return this.comments; } -}); +}); \ No newline at end of file diff --git a/modules/services/notes.js b/modules/services/notes.js index ddcfa3752..5174dbb2f 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -16,15 +16,16 @@ import { xml as d3_xml } from 'd3-request'; import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; import { geoExtent } from '../geo'; -import { - osmNote, -} from '../osm'; - import { utilRebind, utilIdleWorker } from '../util'; +import { + osmNote +} from '../osm'; +import { actionRestrictTurn } from '../actions'; + var urlroot = 'https://api.openstreetmap.org', _notesCache, dispatch = d3_dispatch('loadedNotes', 'loading'), @@ -111,7 +112,7 @@ function parseComments(comments) { } var parsers = { - note: function parseNote(obj) { + note: function parseNote(obj, uid) { var attrs = obj.attributes; var childNodes = obj.childNodes; var parsedNote = {}; @@ -130,6 +131,9 @@ var parsers = { } }); + parsedNote.id = uid; + parsedNote.type = 'note'; + return { minX: parsedNote.loc[0], minY: parsedNote.loc[1], @@ -151,17 +155,19 @@ function parse(xml, callback, options) { var parser = parsers[child.nodeName]; if (parser) { - // TODO: change how a note uid is parsed. Nodes & notes share 'n' + id combination var childNodes = child.childNodes; - var id; - var i; - for (i = 0; i < childNodes.length; i++) { - if (childNodes[i].nodeName === 'id') { id = childNodes[i].nodeName; } - } - if (options.cache && _entityCache[id]) { + + var uid; + _forEach(childNodes, function(node) { + if (node.nodeName === 'id') { + uid = child.nodeName + node.innerHTML; + } + }); + + if (options.cache && _entityCache[uid]) { return null; } - return parser(child); + return parser(child, uid); } } utilIdleWorker(children, parseChild, callback); diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 58e6f94d3..3d422f987 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -3,12 +3,16 @@ import { select as d3_select } from 'd3-selection'; import { svgPointTransform } from './index'; import { services } from '../services'; +import { uiNoteEditor } from '../ui'; + export function svgNotes(projection, context, dispatch) { var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); var minZoom = 12; var layer = d3_select(null); var _notes; + var noteEditor = uiNoteEditor(context); + function init() { if (svgNotes.initialized) return; // run once svgNotes.enabled = false; @@ -57,6 +61,20 @@ export function svgNotes(projection, context, dispatch) { .on('end', editOff); } + function click(d) { + context.ui().sidebar.show(noteEditor, d); + } + + function mouseover(d) { + context.ui().sidebar.show(noteEditor, d); + } + + function mouseout(d) { + // TODO: check if the item was clicked. If so, it should remain on the sidebar. + // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js + context.ui().sidebar.hide(); + } + function update() { var service = getService(); var data = (service ? service.notes(projection) : []); @@ -70,12 +88,15 @@ export function svgNotes(projection, context, dispatch) { var notesEnter = notes.enter() .append('use') - .attr('class', 'note') + .attr('class', function(d) { return 'note ' + d.id; }) .attr('width', '24px') .attr('height', '24px') .attr('x', '-12px') .attr('y', '-12px') - .attr('xlink:href', '#fas-comment-alt'); + .attr('xlink:href', '#fas-comment-alt') + .on('click', click) + .on('mouseover', mouseover) + .on('mouseout', mouseout); notes .merge(notesEnter) diff --git a/modules/ui/index.js b/modules/ui/index.js index d908b7078..112b90d00 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -34,6 +34,7 @@ export { uiMapInMap } from './map_in_map'; export { uiModal } from './modal'; export { uiModes } from './modes'; export { uiNotice } from './notice'; +export { uiNoteEditor } from './note_editor'; export { uiPresetEditor } from './preset_editor'; export { uiPresetIcon } from './preset_icon'; export { uiPresetList } from './preset_list'; diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js new file mode 100644 index 000000000..821dfd02c --- /dev/null +++ b/modules/ui/note_editor.js @@ -0,0 +1,166 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { uiFormFields } from './form_fields'; + + +import { uiField } from './field'; +import { utilRebind } from '../util'; +import { t } from '../util/locale'; + + +export function uiNoteEditor(context) { + var dispatch = d3_dispatch('change'); + var formFields = uiFormFields(context); + var _fieldsArr; + var _noteID; + + function noteEditor(selection, note) { + render(selection, note); + } + + function parseNoteUnresolved(selection, note) { + + var unresolved = selection.selectAll('.noteUnresolved') + .data(note, function(d) { return d.id; }) + .enter() + .append('h3') + .attr('class', 'noteUnresolved') + .text(function(d) { return String(t('note.unresolved') + ' ' + d.id); }); + + selection.merge(unresolved); + return selection; + } + + function parseNoteComments(selection, note) { + + function noteCreator(d) { + var userName = d.user ? d.user : t('note.anonymous'); + return String(t('note.creator') + ' ' + userName + ' ' + t('note.creatorOn') + ' ' + d.date); + } + + var comments = selection + .append('div') + .attr('class', 'comments'); + + var comment = comments.selectAll('.comment') + .data(note.comments, function(d) { return d.uid; }) + .enter() + .append('div') + .attr('class', 'comment'); + + // append the creator + comment + .append('p') + .attr('class', 'commentCreator') + .text(function(d) { return noteCreator(d); }); + + // append the comment + comment + .append('p') + .attr('class', 'commentText') + .text(function(d) { return d.text; }); + + comments.insert('h4', ':first-child') + .text(t('note.description')); + + // TODO: have a better check to highlight the first/author comment (e.g., check if `author: true`) + comments.select('div') + .attr('class', 'comment-first'); + + + selection.merge(comments); + return selection; + } + + function render(selection, note) { + + var exampleNote = { + close_url: 'example_close_url', + comment_url: 'example_comment_url', + comments: [ + { + action: 'opened', + date: '2016-11-20 00:50:20 UTC', + html: '<p>Test comment1.</p>', + text: 'Test comment1', + uid: '111111', + user: 'User1', + user_url: 'example_user_url1' + }, + { + action: 'opened', + date: '2016-11-20 00:50:20 UTC', + html: '<p>Test comment2.</p>', + text: 'Test comment2', + uid: '222222', + user: 'User2', + user_url: 'example_user_url2' + }, + { + action: 'opened', + date: '2016-11-20 00:50:20 UTC', + html: '<p>Test comment3.</p>', + text: 'Test comment3', + uid: '333333', + user: 'User3', + user_url: 'example_user_url3' + } + ], + date_created: '2016-11-20 00:50:20 UTC', + id: 'note789148', + loc: [ + -120.0219036, + 34.4611879 + ], + status: 'open', + type: 'note', + url: 'https://api.openstreetmap.org/api/0.6/notes/789148', + visible: true + }; + + var currentNote = note ? [note] : [exampleNote]; + + var author = currentNote[0].comments[0]; + author.author = true; + + var header = selection.selectAll('.header') + .data([0]); + + header.enter() + .append('div') + .attr('class', 'header fillL') + .append('h3') + .text(t('note.title')); + + var body = selection.selectAll('.body') + .data([0]); + + body = body.enter() + .append('div') + .attr('class', 'body') + .merge(body); + + // Note Section + var noteSection = body.selectAll('.changeset-editor') + .data([0]); + + noteSection = noteSection.enter() + .append('div') + .attr('class', 'modal-section changeset-editor') + .merge(noteSection); + + noteSection = noteSection.call(parseNoteUnresolved, currentNote); + + noteSection = noteSection.call(parseNoteComments, currentNote[0]); + // TODO: revisit commit.js, changeset_editor.js to get warnings, fields array, button toggles, etc. + } + + noteEditor.noteID = function(_) { + if (!arguments.length) return _noteID; + if (_noteID === _) return noteEditor; + _noteID = _; + _fieldsArr = null; + return noteEditor; + }; + + return utilRebind(noteEditor, dispatch, 'on'); +} \ No newline at end of file diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 0062b7031..8782097d4 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -1,12 +1,26 @@ import _throttle from 'lodash-es/throttle'; 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), + noteEditor = uiNoteEditor(context), + current, + wasNote; + + function isNote(id) { + var isNote = (id && id.slice(0,4) === 'note') ? id.slice(0,4) : null; + // TODO: have a better check, perhaps see if the hover class is activated on a note + if (!isNote && wasNote) { + wasNote = false; + sidebar.hide(); + } else if (isNote) { + wasNote = true; + sidebar.show(noteEditor); + } + } function sidebar(selection) { var featureListWrap = selection @@ -21,6 +35,8 @@ export function uiSidebar(context) { function hover(id) { + // isNote(id); TODO: instantiate check if needed + if (!current && context.hasEntity(id)) { featureListWrap .classed('inspector-hidden', true); @@ -46,6 +62,7 @@ export function uiSidebar(context) { inspector .state('hide'); } + // } // TODO: - remove if note check logic is moved } @@ -82,7 +99,7 @@ export function uiSidebar(context) { }; - sidebar.show = function(component) { + sidebar.show = function(component, element) { featureListWrap .classed('inspector-hidden', true); inspectorWrap @@ -92,7 +109,7 @@ export function uiSidebar(context) { current = selection .append('div') .attr('class', 'sidebar-component') - .call(component); + .call(component, element); }; From b3f7b06f66959c3956c1ad25abfeb973a8908998 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 29 Jun 2018 20:01:30 -0400 Subject: [PATCH 15/77] merged: services/notes into /osm; todo: fix some caching & rendering --- modules/services/notes.js | 4 +- modules/services/osm.js | 157 ++++++++++++++++++++++++++++++++++---- modules/svg/notes.js | 17 +++-- 3 files changed, 158 insertions(+), 20 deletions(-) diff --git a/modules/services/notes.js b/modules/services/notes.js index 5174dbb2f..a78c8e9ef 100644 --- a/modules/services/notes.js +++ b/modules/services/notes.js @@ -64,7 +64,7 @@ function getTiles(projection) { s / 2 - projection.translate()[0], s / 2 - projection.translate()[1]]; - return d3_geoTile() + var tiles = d3_geoTile() .scaleExtent([tileZoom, tileZoom]) .scale(s) .size(projection.clipExtent()[1]) @@ -82,6 +82,8 @@ function getTiles(projection) { ) }; }); + + return tiles; } function getLoc(attrs) { diff --git a/modules/services/osm.js b/modules/services/osm.js index 9ead1f5b0..6a661b2ae 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -8,6 +8,10 @@ import _isEmpty from 'lodash-es/isEmpty'; import _map from 'lodash-es/map'; import _uniq from 'lodash-es/uniq'; +import rbush from 'rbush'; + +var _notesCache = {}; + import { dispatch as d3_dispatch } from 'd3-dispatch'; import { xml as d3_xml } from 'd3-request'; @@ -19,13 +23,14 @@ import { osmEntity, osmNode, osmRelation, - osmWay + osmWay, + osmNote } from '../osm'; import { utilRebind, utilIdleWorker } 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, @@ -39,12 +44,14 @@ var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; var _tiles = { loaded: {}, inflight: {} }; var _changeset = {}; var _entityCache = {}; +var _noteTileCache = { loaded: {}, inflight: {}, rtree: rbush() }; var _connectionID = 1; var _tileZoom = 16; var _rateLimitError; var _userChangesets; var _userDetails; var _off; +var _loadingNotes = false; function authLoading() { @@ -113,6 +120,28 @@ function getVisible(attrs) { } +function parseComments(comments) { + var parsedComments = []; + + // for each comment + _forEach(comments, function(comment) { + if (comment.nodeName === 'comment') { + var childNodes = comment.childNodes; + var parsedComment = {}; + + _forEach(childNodes, function(node) { + if (node.nodeName !== '#text') { + var nodeName = node.nodeName; + parsedComment[nodeName] = node.innerHTML; + } + }); + if (parsedComment) { parsedComments.push(parsedComment); } + } + }); + return parsedComments; +} + + var parsers = { node: function nodeData(obj, uid) { var attrs = obj.attributes; @@ -157,6 +186,37 @@ var parsers = { tags: getTags(obj), members: getMembers(obj) }); + }, + + note: function parseNote(obj, uid) { + var attrs = obj.attributes; + var childNodes = obj.childNodes; + var parsedNote = {}; + + parsedNote.loc = getLoc(attrs); + + _forEach(childNodes, function(node) { + if (node.nodeName !== '#text') { + var nodeName = node.nodeName; + // if the element is comments, parse the comments + if (nodeName === 'comments') { + parsedNote[nodeName] = parseComments(node.childNodes); + } else { + parsedNote[nodeName] = node.innerHTML; + } + } + }); + + parsedNote.id = uid; + parsedNote.type = 'note'; + + return { + minX: parsedNote.loc[0], + minY: parsedNote.loc[1], + maxX: parsedNote.loc[0], + maxY: parsedNote.loc[1], + data: new osmNote(parsedNote) + }; } }; @@ -171,8 +231,24 @@ function parse(xml, callback, options) { 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]) { + + var uid; + var cache = _loadingNotes ? _notesCache : _entityCache; + + // if loading notes, get the note id + if (_loadingNotes) { + var childNodes = child.childNodes; + _forEach(childNodes, function(node) { + if (node.nodeName === 'id') { + uid = child.nodeName + node.innerHTML; + } + }); + } + // otherwise, get the osmEntity id + else { + uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); + } + if (options.cache && cache[uid]) { return null; } return parser(child, uid); @@ -200,6 +276,13 @@ export default { _tiles = { loaded: {}, inflight: {} }; _changeset = {}; _entityCache = {}; + + if (_noteTileCache && _noteTileCache.inflight) { + _forEach(_noteTileCache.inflight, abortRequest); + } + _noteTileCache = { loaded: {}, inflight: {}, rtree: rbush() }; + _notesCache = {}; + return this; }, @@ -272,7 +355,11 @@ export default { parse(xml, function (entities) { if (options.cache) { for (var i in entities) { - _entityCache[entities[i].id] = true; + if (_loadingNotes) { + _notesCache[entities[i].id] = true; + } else { + _entityCache[entities[i].id] = true; + } } } callback(null, entities); @@ -573,6 +660,9 @@ export default { if (_off) return; var that = this; + + var cache = _loadingNotes ? _noteTileCache : _tiles; + 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); @@ -598,36 +688,48 @@ export default { }; }); - _filter(_tiles.inflight, function(v, i) { + _filter(cache.inflight, function(v, i) { var wanted = _find(tiles, function(tile) { return i === tile.id; }); - if (!wanted) delete _tiles.inflight[i]; + if (!wanted) delete cache.inflight[i]; return !wanted; }).map(abortRequest); + // check if loading entities, or notes + var path = _loadingNotes ? '/api/0.6/notes?bbox=' : '/api/0.6/map?bbox='; + tiles.forEach(function(tile) { var id = tile.id; - if (_tiles.loaded[id] || _tiles.inflight[id]) return; + if (cache.loaded[id] || cache.inflight[id]) return; - if (_isEmpty(_tiles.inflight)) { + if (_isEmpty(cache.inflight)) { dispatch.call('loading'); } - _tiles.inflight[id] = that.loadFromAPI( - '/api/0.6/map?bbox=' + tile.extent.toParam(), + cache.inflight[id] = that.loadFromAPI( + path + tile.extent.toParam(), function(err, parsed) { - delete _tiles.inflight[id]; + delete cache.inflight[id]; if (!err) { - _tiles.loaded[id] = true; + cache.loaded[id] = true; + } + // NOTE: if zoom above min zoom & notes turned on before osm, osm won't render + // TODO: either pick this option, or the next one + if (_loadingNotes) { + cache.rtree.load(parsed); } + // TODO: figure out how this callback should handle parsed results if (callback) { callback(err, _extend({ data: parsed }, tile)); } - if (_isEmpty(_tiles.inflight)) { + if (_isEmpty(cache.inflight)) { + if (_loadingNotes) { + dispatch.call('loadedNotes'); + } dispatch.call('loaded'); } } @@ -696,5 +798,32 @@ export default { } return oauth.authenticate(done); + }, + + loadNotes: function(projection, dimensions, callback) { + var that = this; + _loadingNotes = true; + that.loadTiles(projection, dimensions, callback); + }, + + // TODO: possibly remove rtree & refactor caches + 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 _noteTileCache.rtree.search(bbox) + .map(function(d) { return d.data; }); + }, + + loadedNotes: function(_) { + if (!arguments.length) return _noteTileCache.loaded; + _noteTileCache.loaded = _; + return this; + }, + + notesCache: function() { + return _noteTileCache; } }; diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 3d422f987..7f9492456 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -30,10 +30,10 @@ export function svgNotes(projection, context, dispatch) { } function getService() { - if (services.notes && !_notes) { - _notes = services.notes; - _notes.event.on('loadedNotes', throttledRedraw); - } else if (!services.notes && _notes) { + if (services.osm && !_notes) { + _notes = services.osm; + _notes.on('loadedNotes', throttledRedraw); + } else if (!services.osm && _notes) { _notes = null; } @@ -107,6 +107,13 @@ export function svgNotes(projection, context, dispatch) { var enabled = svgNotes.enabled, service = getService(); + function dimensions() { + return [window.innerWidth, window.innerHeight]; + } + function done() { + console.log('placeholder done within svg/notes.upload.done'); + } + layer = selection.selectAll('.layer-notes') .data(service ? [0] : []); @@ -123,7 +130,7 @@ export function svgNotes(projection, context, dispatch) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); update(); - service.loadNotes(projection); + service.loadNotes(projection, dimensions(), done); } else { editOff(); } From fd1d2f006b4d6ab27b4f9a99f6dddaf89630115f Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Jun 2018 22:39:40 -0400 Subject: [PATCH 16/77] Fix the osmNote initializer --- modules/osm/note.js | 45 +++++++++++++++++---------------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/modules/osm/note.js b/modules/osm/note.js index c83ce950f..135d7e599 100644 --- a/modules/osm/note.js +++ b/modules/osm/note.js @@ -1,18 +1,25 @@ import _extend from 'lodash-es/extend'; -import { osmEntity } from './entity'; import { geoExtent } from '../geo'; -import { debug } from '../index'; - export function osmNote() { - if (!(this instanceof osmNote)) return; - - this.initialize(arguments); - return this; + 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', @@ -31,20 +38,8 @@ _extend(osmNote.prototype, { } } - if (!this.id && this.type) { - this.id = osmEntity.id(this.type); - } - if (!this.hasOwnProperty('visible')) { - this.visible = true; - } - - if (debug) { - Object.freeze(this); - Object.freeze(this.tags); - - if (this.loc) Object.freeze(this.loc); - if (this.nodes) Object.freeze(this.nodes); - if (this.members) Object.freeze(this.members); + if (!this.id) { + this.id = osmNote.id(); } return this; @@ -54,12 +49,6 @@ _extend(osmNote.prototype, { return new geoExtent(this.loc); }, - geometry: function(graph) { - return graph.transient(this, 'geometry', function() { - return graph.isPoi(this) ? 'point' : 'vertex'; - }); - }, - getID: function() { return this.id; }, @@ -71,4 +60,4 @@ _extend(osmNote.prototype, { getComments: function() { return this.comments; } -}); \ No newline at end of file +}); From ede561080dccd87562d546d6cee1474cdb498da9 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 29 Jun 2018 22:47:14 -0400 Subject: [PATCH 17/77] Add update method, remove getters.. This makes the osmNote work a bit more like other osm objects in iD. - When working with the osm objects, we'll treat them as immutable. So all modifications will be through the update method: e.g. can do this in a repl, like chrome devtools console: > n = iD.osmNote() osmNote { id: -1 } > n = n.update({ foo: 'bar' }); osmNote { foo: "bar", id: -1, v: 1 } - none of the other osm objects have getters, and in JavaScript all the properties are public anyway --- modules/osm/note.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/modules/osm/note.js b/modules/osm/note.js index 135d7e599..a9cd67b89 100644 --- a/modules/osm/note.js +++ b/modules/osm/note.js @@ -49,15 +49,7 @@ _extend(osmNote.prototype, { return new geoExtent(this.loc); }, - getID: function() { - return this.id; - }, - - getType: function() { - return this.type; - }, - - getComments: function() { - return this.comments; + update: function(attrs) { + return osmNote(this, attrs, {v: 1 + (this.v || 0)}); } }); From 1b06dbd8c95a09f36c18190ebff2f75761de2f2e Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 29 Jun 2018 23:36:11 -0400 Subject: [PATCH 18/77] delete: demo select file (unintentional commit) --- modules/behavior/TAH_select.js | 225 --------------------------------- 1 file changed, 225 deletions(-) delete mode 100644 modules/behavior/TAH_select.js diff --git a/modules/behavior/TAH_select.js b/modules/behavior/TAH_select.js deleted file mode 100644 index c7ba6da58..000000000 --- a/modules/behavior/TAH_select.js +++ /dev/null @@ -1,225 +0,0 @@ -import _without from 'lodash-es/without'; - -import { - event as d3_event, - mouse as d3_mouse, - select as d3_select -} from 'd3-selection'; - -import { geoVecLength } from '../geo'; - -import { - modeBrowse, - modeSelect -} from '../modes'; - -import { - osmEntity, - osmNote -} from '../osm'; - - -export function behaviorSelect(context) { - var lastMouse = null; - var suppressMenu = true; - var tolerance = 4; - var p1 = null; - - - function point() { - return d3_mouse(context.container().node()); - } - - - function keydown() { - var e = d3_event; - if (e && e.shiftKey) { - context.surface() - .classed('behavior-multiselect', true); - } - - if (e && e.keyCode === 93) { // context menu - e.preventDefault(); - e.stopPropagation(); - } - } - - - function keyup() { - var e = d3_event; - if (!e || !e.shiftKey) { - context.surface() - .classed('behavior-multiselect', false); - } - - - if (e && e.keyCode === 93) { // context menu - e.preventDefault(); - e.stopPropagation(); - contextmenu(); - } - } - - - function mousedown() { - if (!p1) p1 = point(); - d3_select(window) - .on('mouseup.select', mouseup, true); - - var isShowAlways = +context.storage('edit-menu-show-always') === 1; - suppressMenu = !isShowAlways; - } - - - function mousemove() { - if (d3_event) lastMouse = d3_event; - } - - - function mouseup() { - click(); - } - - - function contextmenu() { - var e = d3_event; - e.preventDefault(); - e.stopPropagation(); - - if (!+e.clientX && !+e.clientY) { - if (lastMouse) { - e.sourceEvent = lastMouse; - } else { - return; - } - } - - if (!p1) p1 = point(); - suppressMenu = false; - click(); - } - - - function click() { - d3_select(window) - .on('mouseup.select', null, true); - - if (!p1) return; - var p2 = point(); - var dist = geoVecLength(p1, p2); - - p1 = null; - if (dist > tolerance) { - return; - } - - var isMultiselect = d3_event.shiftKey || d3_select('#surface .lasso').node(); - var isShowAlways = +context.storage('edit-menu-show-always') === 1; - var datum = d3_event.target.__data__ || (lastMouse && lastMouse.target.__data__); - var mode = context.mode(); - - var entity; - if (datum instanceof osmNote) { - entity = datum; - } else { - entity = datum && datum.properties && datum.properties.entity; - } - if (entity) datum = entity; - - if (datum && datum.type === 'midpoint') { - datum = datum.parents[0]; - } - - if (!(datum instanceof osmEntity) && !(datum instanceof osmNote)) { - // clicked nothing.. - if (!isMultiselect && mode.id !== 'browse') { - context.enter(modeBrowse(context)); - } - - } else { - // clicked an entity.. (or a notes) - var selectedIDs = context.selectedIDs(); - - if (!isMultiselect) { - if (selectedIDs.length > 1 && (!suppressMenu && !isShowAlways)) { - // multiple things already selected, just show the menu... - mode.suppressMenu(false).reselect(); - } else { - // select a single thing.. - context.enter(modeSelect(context, [datum.id]).suppressMenu(suppressMenu)); - } - - } else { - if (selectedIDs.indexOf(datum.id) !== -1) { - // clicked entity is already in the selectedIDs list.. - if (!suppressMenu && !isShowAlways) { - // don't deselect clicked entity, just show the menu. - mode.suppressMenu(false).reselect(); - } else { - // deselect clicked entity, then reenter select mode or return to browse mode.. - selectedIDs = _without(selectedIDs, datum.id); - context.enter(selectedIDs.length ? modeSelect(context, selectedIDs) : modeBrowse(context)); - } - } else { - // clicked entity is not in the selected list, add it.. - selectedIDs = selectedIDs.concat([datum.id]); - context.enter(modeSelect(context, selectedIDs).suppressMenu(suppressMenu)); - } - } - } - - // reset for next time.. - suppressMenu = true; - } - - - var behavior = function(selection) { - lastMouse = null; - suppressMenu = true; - p1 = null; - - d3_select(window) - .on('keydown.select', keydown) - .on('keyup.select', keyup) - .on('contextmenu.select-window', function() { - // Edge and IE really like to show the contextmenu on the - // menubar when user presses a keyboard menu button - // even after we've already preventdefaulted the key event. - var e = d3_event; - if (+e.clientX === 0 && +e.clientY === 0) { - d3_event.preventDefault(); - d3_event.stopPropagation(); - } - }); - - selection - .on('mousedown.select', mousedown) - .on('mousemove.select', mousemove) - .on('contextmenu.select', contextmenu); - - if (d3_event && d3_event.shiftKey) { - context.surface() - .classed('behavior-multiselect', true); - } - }; - - - behavior.off = function(selection) { - d3_select(window) - .on('keydown.select', null) - .on('keyup.select', null) - .on('contextmenu.select-window', null) - .on('mouseup.select', null, true); - - selection - .on('mousedown.select', null) - .on('mousemove.select', null) - .on('contextmenu.select', null); - - context.surface() - .classed('behavior-multiselect', false); - }; - - - return behavior; -} From 229484a940eeb1d8ef08495c39642276c599c323 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 30 Jun 2018 02:35:31 -0400 Subject: [PATCH 19/77] Cleanup osm service notes and caches, remove state variable This seems like a lot but the main things here are: - remove the _loadingTiles "global" variable. It was causing problems because it was being checked from the callbacks, which are async. - cleanup the caches - use DOM API getElementsByTagName('id') to get note id - set a lower tilezoom z13 for notes (meaning: fetch larger bounding boxes) --- modules/services/osm.js | 182 +++++++++++++++++++--------------------- modules/svg/notes.js | 11 +-- 2 files changed, 90 insertions(+), 103 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 6a661b2ae..87af1beb5 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -10,8 +10,6 @@ import _uniq from 'lodash-es/uniq'; import rbush from 'rbush'; -var _notesCache = {}; - import { dispatch as d3_dispatch } from 'd3-dispatch'; import { xml as d3_xml } from 'd3-request'; @@ -19,12 +17,13 @@ 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 { osmEntity, osmNode, + osmNote, osmRelation, - osmWay, - osmNote + osmWay } from '../osm'; import { utilRebind, utilIdleWorker } from '../util'; @@ -41,17 +40,18 @@ var oauth = osmAuth({ }); var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; -var _tiles = { loaded: {}, inflight: {} }; +var _tileCache = { loaded: {}, inflight: {} }; +var _noteCache = { loaded: {}, inflight: {}, rtree: rbush() }; var _changeset = {}; -var _entityCache = {}; -var _noteTileCache = { loaded: {}, inflight: {}, rtree: rbush() }; +var _seenEntity = {}; + var _connectionID = 1; var _tileZoom = 16; +var _noteZoom = 13; var _rateLimitError; var _userChangesets; var _userDetails; var _off; -var _loadingNotes = false; function authLoading() { @@ -146,7 +146,7 @@ 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, @@ -230,29 +230,19 @@ function parse(xml, callback, options) { function parseChild(child) { var parser = parsers[child.nodeName]; - if (parser) { + if (!parser) return; - var uid; - var cache = _loadingNotes ? _notesCache : _entityCache; - - // if loading notes, get the note id - if (_loadingNotes) { - var childNodes = child.childNodes; - _forEach(childNodes, function(node) { - if (node.nodeName === 'id') { - uid = child.nodeName + node.innerHTML; - } - }); - } - // otherwise, get the osmEntity id - else { - uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); - } - if (options.cache && cache[uid]) { - return null; - } - return parser(child, uid); + var uid; + if (child.nodeName === 'note') { + uid = child.getElementsByTagName('id')[0].textContent; + } else { + uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); } + + if (options.cache && _seenEntity[uid]) { + return null; + } + return parser(child, uid); } utilIdleWorker(children, parseChild, callback); @@ -271,17 +261,15 @@ export default { _userChangesets = undefined; _userDetails = undefined; _rateLimitError = undefined; - _forEach(_tiles.inflight, abortRequest); - if (_changeset.inflight) abortRequest(_changeset.inflight); - _tiles = { loaded: {}, inflight: {} }; - _changeset = {}; - _entityCache = {}; - if (_noteTileCache && _noteTileCache.inflight) { - _forEach(_noteTileCache.inflight, abortRequest); - } - _noteTileCache = { loaded: {}, inflight: {}, rtree: rbush() }; - _notesCache = {}; + _forEach(_tileCache.inflight, abortRequest); + _forEach(_noteCache.inflight, abortRequest); + if (_changeset.inflight) abortRequest(_changeset.inflight); + + _tileCache = { loaded: {}, inflight: {} }; + _noteCache = { loaded: {}, inflight: {}, rtree: rbush() }; + _changeset = {}; + _seenEntity = {}; return this; }, @@ -355,11 +343,7 @@ export default { parse(xml, function (entities) { if (options.cache) { for (var i in entities) { - if (_loadingNotes) { - _notesCache[entities[i].id] = true; - } else { - _entityCache[entities[i].id] = true; - } + _seenEntity[entities[i].id] = true; } } callback(null, entities); @@ -656,56 +640,65 @@ export default { }, - loadTiles: function(projection, dimensions, callback) { + loadTiles: function(projection, dimensions, callback, loadingNotes) { if (_off) return; var that = this; - var cache = _loadingNotes ? _noteTileCache : _tiles; + // check if loading entities, or notes + var path, cache, tilezoom; + if (loadingNotes) { + path = '/api/0.6/notes?bbox='; + cache = _noteCache; + tilezoom = _noteZoom; + } 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]; + .translate(projection.translate()); - return { - id: tile.toString(), - extent: geoExtent( - projection.invert([x, y + ts]), - projection.invert([x + ts, y])) - }; - }); + 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(cache.inflight, function(v, i) { - var wanted = _find(tiles, function(tile) { - return i === tile.id; - }); - if (!wanted) delete cache.inflight[i]; + var wanted = _find(tiles, function(tile) { return i === tile.id; }); + if (!wanted) { + delete cache.inflight[i]; + } return !wanted; }).map(abortRequest); - // check if loading entities, or notes - var path = _loadingNotes ? '/api/0.6/notes?bbox=' : '/api/0.6/map?bbox='; tiles.forEach(function(tile) { var id = tile.id; - if (cache.loaded[id] || cache.inflight[id]) return; - - if (_isEmpty(cache.inflight)) { - dispatch.call('loading'); + if (!loadingNotes && _isEmpty(cache.inflight)) { + dispatch.call('loading'); // start the spinner } cache.inflight[id] = that.loadFromAPI( @@ -715,23 +708,19 @@ export default { if (!err) { cache.loaded[id] = true; } - // NOTE: if zoom above min zoom & notes turned on before osm, osm won't render - // TODO: either pick this option, or the next one - if (_loadingNotes) { + + if (loadingNotes) { cache.rtree.load(parsed); - } - - // TODO: figure out how this callback should handle parsed results - if (callback) { - callback(err, _extend({ data: parsed }, tile)); - } - - if (_isEmpty(cache.inflight)) { - if (_loadingNotes) { - dispatch.call('loadedNotes'); + dispatch.call('loadedNotes'); + } else { + if (callback) { + callback(err, _extend({ data: parsed }, tile)); + } + if (_isEmpty(cache.inflight)) { + dispatch.call('loaded'); // stop the spinner } - dispatch.call('loaded'); } + } ); }); @@ -761,8 +750,8 @@ export default { loadedTiles: function(_) { - if (!arguments.length) return _tiles.loaded; - _tiles.loaded = _; + if (!arguments.length) return _tileCache.loaded; + _tileCache.loaded = _; return this; }, @@ -800,30 +789,31 @@ export default { return oauth.authenticate(done); }, - loadNotes: function(projection, dimensions, callback) { - var that = this; - _loadingNotes = true; - that.loadTiles(projection, dimensions, callback); + + loadNotes: function(projection, dimensions) { + this.loadTiles(projection, dimensions, null, true); // true = loadingNotes }, - // TODO: possibly remove rtree & refactor caches + 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 _noteTileCache.rtree.search(bbox) + return _noteCache.rtree.search(bbox) .map(function(d) { return d.data; }); }, + loadedNotes: function(_) { - if (!arguments.length) return _noteTileCache.loaded; - _noteTileCache.loaded = _; + if (!arguments.length) return _noteCache.loaded; + _noteCache.loaded = _; return this; }, + notesCache: function() { - return _noteTileCache; + return _noteCache; } }; diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 7f9492456..e9ad03cd2 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -104,15 +104,12 @@ export function svgNotes(projection, context, dispatch) { } function drawNotes(selection) { - var enabled = svgNotes.enabled, - service = getService(); + var enabled = svgNotes.enabled; + var service = getService(); function dimensions() { return [window.innerWidth, window.innerHeight]; } - function done() { - console.log('placeholder done within svg/notes.upload.done'); - } layer = selection.selectAll('.layer-notes') .data(service ? [0] : []); @@ -130,7 +127,7 @@ export function svgNotes(projection, context, dispatch) { if (service && ~~context.map().zoom() >= minZoom) { editOn(); update(); - service.loadNotes(projection, dimensions(), done); + service.loadNotes(projection, dimensions()); } else { editOff(); } @@ -151,4 +148,4 @@ export function svgNotes(projection, context, dispatch) { init(); return drawNotes; -} \ No newline at end of file +} From 2f8efee26a2be307784abbd3a49e3a9977c7a9ad Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 30 Jun 2018 03:47:03 -0400 Subject: [PATCH 20/77] Draw 2x icons (shadow and fill) so they stand out more --- css/60_photos.css | 7 ++++++- modules/svg/notes.js | 34 +++++++++++++++++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/css/60_photos.css b/css/60_photos.css index a372a7a9d..7213ee728 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -120,7 +120,12 @@ .layer-notes * { pointer-events: visible; cursor: pointer; - color: #eebb00; +} +.layer-notes .note-shadow { + color: #000; +} +.layer-notes .note-fill { + color: #ee3; } diff --git a/modules/svg/notes.js b/modules/svg/notes.js index e9ad03cd2..ce869ddab 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -10,6 +10,7 @@ export function svgNotes(projection, context, dispatch) { var minZoom = 12; var layer = d3_select(null); var _notes; + var _selected; var noteEditor = uiNoteEditor(context); @@ -62,6 +63,7 @@ export function svgNotes(projection, context, dispatch) { } function click(d) { + _selected = d; context.ui().sidebar.show(noteEditor, d); } @@ -87,19 +89,37 @@ export function svgNotes(projection, context, dispatch) { .remove(); var notesEnter = notes.enter() - .append('use') - .attr('class', function(d) { return 'note ' + d.id; }) - .attr('width', '24px') - .attr('height', '24px') - .attr('x', '-12px') - .attr('y', '-12px') - .attr('xlink:href', '#fas-comment-alt') + .append('g') + .attr('class', function(d) { return 'note note-' + d.id; }) .on('click', click) .on('mouseover', mouseover) .on('mouseout', mouseout); + notesEnter + .append('use') + .attr('class', 'note-shadow') + .attr('width', '24px') + .attr('height', '24px') + .attr('x', '-12px') + .attr('y', '-24px') + .attr('xlink:href', '#fas-comment-alt') + + notesEnter + .append('use') + .attr('class', 'note-fill') + .attr('width', '20px') + .attr('height', '20px') + .attr('x', '-10px') + .attr('y', '-22px') + .attr('xlink:href', '#fas-comment-alt') + notes .merge(notesEnter) + .sort(function(a, b) { + return (a === _selected) ? 1 + : (b === _selected) ? -1 + : b.loc[1] - a.loc[1]; // sort Y + }) .attr('transform', transform); } From bcc16697162ba737caeb3224a06c53f01751c7cf Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 30 Jun 2018 10:05:11 -0400 Subject: [PATCH 21/77] Move note css from the photos.css into a new css file data.css --- css/60_photos.css | 40 ---------------------------------------- css/65_data.css | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 40 deletions(-) create mode 100644 css/65_data.css diff --git a/css/60_photos.css b/css/60_photos.css index 7213ee728..63173726d 100644 --- a/css/60_photos.css +++ b/css/60_photos.css @@ -113,46 +113,6 @@ stroke-opacity: 1; } -/* Notes Layer */ -.layer-notes { - pointer-events: none; -} -.layer-notes * { - pointer-events: visible; - cursor: pointer; -} -.layer-notes .note-shadow { - color: #000; -} -.layer-notes .note-fill { - color: #ee3; -} - - -/* TODO: possibly move this note detail .css to another file */ - -.comment-first { - background-color:#ddd; - border-radius: 5px; - padding: 5px; - margin: 5px auto; -} - -.comment { - background-color:#fff; - border-radius: 5px; - padding: 5px; - margin: 5px auto; -} - -.commentCreator { - color: #666; -} - -.commentText { - margin: 20px auto; -} - /* Streetside Image Layer */ .layer-streetside-images { diff --git a/css/65_data.css b/css/65_data.css new file mode 100644 index 000000000..e8c1b22a5 --- /dev/null +++ b/css/65_data.css @@ -0,0 +1,35 @@ + +/* OSM Notes Layer */ +.layer-notes { + pointer-events: none; +} +.layer-notes * { + pointer-events: visible; + cursor: pointer; +} +.layer-notes .note-shadow { + color: #000; +} +.layer-notes .note-fill { + color: #ee3; +} + +/* OSM Note UI */ +.comment-first { + background-color:#ddd; + border-radius: 5px; + padding: 5px; + margin: 5px auto; +} +.comment { + background-color:#fff; + border-radius: 5px; + padding: 5px; + margin: 5px auto; +} +.commentCreator { + color: #666; +} +.commentText { + margin: 20px auto; +} From f3d31f30759fb803d1ec04fa37249c71040d591f Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 30 Jun 2018 10:44:28 -0400 Subject: [PATCH 22/77] Update css styles for notes - open notes are red (default) - resolved notes are green - orange on hover - yellow on select (also added centerEase to note location) --- css/65_data.css | 20 +++++++++++++++----- modules/svg/notes.js | 37 ++++++++++++++++++++++++++++--------- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index e8c1b22a5..b66b48c11 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -7,22 +7,32 @@ pointer-events: visible; cursor: pointer; } -.layer-notes .note-shadow { + +.layer-notes .note .note-shadow { color: #000; } -.layer-notes .note-fill { - color: #ee3; +.layer-notes .note .note-fill { + color: #ff3300; +} +.layer-notes .note.closed .note-fill { + color: #00bb33; +} +.layer-notes .note.hovered .note-fill { + color: #eebb00; +} +.layer-notes .note.selected .note-fill { + color: #ffee00; } /* OSM Note UI */ .comment-first { - background-color:#ddd; + background-color: #ddd; border-radius: 5px; padding: 5px; margin: 5px auto; } .comment { - background-color:#fff; + background-color: #fff; border-radius: 5px; padding: 5px; margin: 5px auto; diff --git a/modules/svg/notes.js b/modules/svg/notes.js index ce869ddab..f0f77dcee 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -62,21 +62,36 @@ export function svgNotes(projection, context, dispatch) { .on('end', editOff); } - function click(d) { - _selected = d; - context.ui().sidebar.show(noteEditor, d); + + function click(which) { + _selected = which; + context.map().centerEase(which.loc); + + layer.selectAll('.note') + .classed('selected', function(d) { return d === _selected; }); + + context.ui().sidebar.show(noteEditor, which); } - function mouseover(d) { - context.ui().sidebar.show(noteEditor, d); + + function mouseover(which) { + layer.selectAll('.note') + .classed('hovered', function(d) { return d === which; }); + + context.ui().sidebar.show(noteEditor, which); } - function mouseout(d) { + + function mouseout() { + layer.selectAll('.note') + .classed('hovered', false); + // TODO: check if the item was clicked. If so, it should remain on the sidebar. // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js context.ui().sidebar.hide(); } + function update() { var service = getService(); var data = (service ? service.notes(projection) : []); @@ -88,9 +103,10 @@ export function svgNotes(projection, context, dispatch) { notes.exit() .remove(); + // enter var notesEnter = notes.enter() .append('g') - .attr('class', function(d) { return 'note note-' + d.id; }) + .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }) .on('click', click) .on('mouseover', mouseover) .on('mouseout', mouseout); @@ -102,7 +118,7 @@ export function svgNotes(projection, context, dispatch) { .attr('height', '24px') .attr('x', '-12px') .attr('y', '-24px') - .attr('xlink:href', '#fas-comment-alt') + .attr('xlink:href', '#fas-comment-alt'); notesEnter .append('use') @@ -111,8 +127,9 @@ export function svgNotes(projection, context, dispatch) { .attr('height', '20px') .attr('x', '-10px') .attr('y', '-22px') - .attr('xlink:href', '#fas-comment-alt') + .attr('xlink:href', '#fas-comment-alt'); + // update notes .merge(notesEnter) .sort(function(a, b) { @@ -120,9 +137,11 @@ export function svgNotes(projection, context, dispatch) { : (b === _selected) ? -1 : b.loc[1] - a.loc[1]; // sort Y }) + .classed('selected', function(d) { return d === _selected; }) .attr('transform', transform); } + function drawNotes(selection) { var enabled = svgNotes.enabled; var service = getService(); From 487ec9d8370f2467696ce6c5a84876c67cdc71d5 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sun, 1 Jul 2018 12:48:51 -0400 Subject: [PATCH 23/77] Better spinner event management re: https://github.com/openstreetmap/iD/pull/5107#issuecomment-401617938 --- modules/services/osm.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 87af1beb5..037d23003 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -665,7 +665,7 @@ export default { s / 2 - projection.translate()[1] ]; - // what tiles cover the view + // what tiles cover the view? var tiler = d3_geoTile() .scaleExtent([tilezoom, tilezoom]) .scale(s) @@ -685,6 +685,8 @@ export default { }; }); + // 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) { @@ -693,12 +695,16 @@ export default { return !wanted; }).map(abortRequest); + if (hadRequests && !loadingNotes && _isEmpty(cache.inflight)) { + dispatch.call('loaded'); // stop the spinner + } + // issue new requests.. tiles.forEach(function(tile) { var id = tile.id; if (cache.loaded[id] || cache.inflight[id]) return; if (!loadingNotes && _isEmpty(cache.inflight)) { - dispatch.call('loading'); // start the spinner + dispatch.call('loading'); // start the spinner } cache.inflight[id] = that.loadFromAPI( @@ -717,7 +723,7 @@ export default { callback(err, _extend({ data: parsed }, tile)); } if (_isEmpty(cache.inflight)) { - dispatch.call('loaded'); // stop the spinner + dispatch.call('loaded'); // stop the spinner } } From 9fc94f5d28bc05b557ae36be699abdd5ae40efd4 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 1 Jul 2018 16:28:24 -0400 Subject: [PATCH 24/77] updated: Premium DigitalGlobe description --- dist/locales/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/locales/en.json b/dist/locales/en.json index d0432a58b..dc650c6b4 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -6686,7 +6686,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "DigitalGlobe-Premium is a mosaic composed of DigitalGlobe basemap with select regions filled with +Vivid or custom area of interest imagery, 50cm resolution or better, and refreshed more frequently with ongoing updates.", + "description": "Premium DigitalGlobe satellite imagery.", "name": "DigitalGlobe Premium Imagery" }, "DigitalGlobe-Premium-vintage": { @@ -6700,7 +6700,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "DigitalGlobe-Standard is a curated set of imagery covering 86% of the earth’s landmass, with 30-60cm resolution where available, backfilled by Landsat. Average age is 2.31 years, with some areas updated 2x per year.", + "description": "Standard DigitalGlobe satellite imagery.", "name": "DigitalGlobe Standard Imagery" }, "DigitalGlobe-Standard-vintage": { From 00c8ff4f6911c48250889cd47b79e4893ce93386 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sun, 1 Jul 2018 22:50:28 -0400 Subject: [PATCH 25/77] Don't add notes to the _seenEntity cache re: https://github.com/openstreetmap/iD/commit/229484a940eeb1d8ef08495c39642276c599c323#commitcomment-29560519 --- modules/services/osm.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 037d23003..d0a9b933b 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -237,11 +237,11 @@ function parse(xml, callback, options) { uid = child.getElementsByTagName('id')[0].textContent; } else { uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); + if (options.cache && _seenEntity[uid]) { + return null; // avoid reparsing a "seen" entity + } } - if (options.cache && _seenEntity[uid]) { - return null; - } return parser(child, uid); } @@ -326,7 +326,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 { @@ -343,7 +343,7 @@ export default { parse(xml, function (entities) { if (options.cache) { for (var i in entities) { - _seenEntity[entities[i].id] = true; + _seenEntity[entities[i].id] = true; // avoid re-parsing again later } } callback(null, entities); @@ -707,6 +707,7 @@ export default { dispatch.call('loading'); // start the spinner } + var options = { cache: !loadingNotes }; cache.inflight[id] = that.loadFromAPI( path + tile.extent.toParam(), function(err, parsed) { @@ -726,8 +727,8 @@ export default { dispatch.call('loaded'); // stop the spinner } } - - } + }, + options ); }); }, From 535208beb5c6500d3cb8ed6d490aa54fc48c2ad6 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sun, 1 Jul 2018 23:35:10 -0400 Subject: [PATCH 26/77] Move tests to spec/services/osm.js and remove old notes.js files --- modules/services/index.js | 3 - modules/services/notes.js | 298 ------------------------------------ test/index.html | 1 - test/spec/services/notes.js | 90 ----------- test/spec/services/osm.js | 62 +++++++- 5 files changed, 55 insertions(+), 399 deletions(-) delete mode 100644 modules/services/notes.js delete mode 100644 test/spec/services/notes.js diff --git a/modules/services/index.js b/modules/services/index.js index cd38a0890..789628ed5 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,6 +1,5 @@ import serviceMapillary from './mapillary'; import serviceNominatim from './nominatim'; -import serviceNotes from './notes'; import serviceOpenstreetcam from './openstreetcam'; import serviceOsm from './osm'; import serviceStreetside from './streetside'; @@ -11,7 +10,6 @@ import serviceWikipedia from './wikipedia'; export var services = { geocoder: serviceNominatim, mapillary: serviceMapillary, - notes: serviceNotes, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, streetside: serviceStreetside, @@ -23,7 +21,6 @@ export var services = { export { serviceMapillary, serviceNominatim, - serviceNotes, serviceOpenstreetcam, serviceOsm, serviceStreetside, diff --git a/modules/services/notes.js b/modules/services/notes.js deleted file mode 100644 index a78c8e9ef..000000000 --- a/modules/services/notes.js +++ /dev/null @@ -1,298 +0,0 @@ -import _extend from 'lodash-es/extend'; -import _filter from 'lodash-es/filter'; -import _find from 'lodash-es/find'; -import _forEach from 'lodash-es/forEach'; -import _isEmpty from 'lodash-es/isEmpty'; - -import osmAuth from 'osm-auth'; - -import rbush from 'rbush'; - -var _entityCache = {}; - -import { dispatch as d3_dispatch } from 'd3-dispatch'; -import { xml as d3_xml } from 'd3-request'; - -import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; -import { geoExtent } from '../geo'; - -import { - utilRebind, - utilIdleWorker -} from '../util'; - -import { - osmNote -} from '../osm'; -import { actionRestrictTurn } from '../actions'; - -var urlroot = 'https://api.openstreetmap.org', - _notesCache, - dispatch = d3_dispatch('loadedNotes', 'loading'), - tileZoom = 14; - -// TODO: complete authentication -var oauth = osmAuth({ - url: urlroot, - oauth_consumer_key: '', - oauth_secret: '', - loading: authLoading, - done: authDone -}); - -function authLoading() { - dispatch.call('authLoading'); -} - -function authDone() { - dispatch.call('authDone'); -} - -function authenticated() { - return oauth.authenticated(); -} - -function abortRequest(i) { - i.abort(); -} - -function getTiles(projection) { - var s = projection.scale() * 2 * Math.PI, - z = Math.max(Math.log(s) / Math.log(2) - 8, 0), - ts = 256 * Math.pow(2, z - tileZoom), - origin = [ - s / 2 - projection.translate()[0], - s / 2 - projection.translate()[1]]; - - var tiles = d3_geoTile() - .scaleExtent([tileZoom, tileZoom]) - .scale(s) - .size(projection.clipExtent()[1]) - .translate(projection.translate())() - .map(function(tile) { - var x = tile[0] * ts - origin[0], - y = tile[1] * ts - origin[1]; - - return { - id: tile.toString(), - xyz: tile, - extent: geoExtent( - projection.invert([x, y + ts]), - projection.invert([x + ts, y]) - ) - }; - }); - - return tiles; -} - -function getLoc(attrs) { - var lon = attrs.lon && attrs.lon.value; - var lat = attrs.lat && attrs.lat.value; - return [parseFloat(lon), parseFloat(lat)]; -} - -function parseComments(comments) { - var parsedComments = []; - - // for each comment - _forEach(comments, function(comment) { - if (comment.nodeName === 'comment') { - var childNodes = comment.childNodes; - var parsedComment = {}; - - _forEach(childNodes, function(node) { - if (node.nodeName !== '#text') { - var nodeName = node.nodeName; - parsedComment[nodeName] = node.innerHTML; - } - }); - if (parsedComment) { parsedComments.push(parsedComment); } - } - }); - return parsedComments; -} - -var parsers = { - note: function parseNote(obj, uid) { - var attrs = obj.attributes; - var childNodes = obj.childNodes; - var parsedNote = {}; - - parsedNote.loc = getLoc(attrs); - - _forEach(childNodes, function(node) { - if (node.nodeName !== '#text') { - var nodeName = node.nodeName; - // if the element is comments, parse the comments - if (nodeName === 'comments') { - parsedNote[nodeName] = parseComments(node.childNodes); - } else { - parsedNote[nodeName] = node.innerHTML; - } - } - }); - - parsedNote.id = uid; - parsedNote.type = 'note'; - - return { - minX: parsedNote.loc[0], - minY: parsedNote.loc[1], - maxX: parsedNote.loc[0], - maxY: parsedNote.loc[1], - data: new osmNote(parsedNote) - }; - } -}; - -function parse(xml, callback, options) { - options = _extend({ cache: true }, options); - if (!xml || !xml.childNodes) return; - - var root = xml.childNodes[0]; - var children = root.childNodes; - - function parseChild(child) { - var parser = parsers[child.nodeName]; - if (parser) { - - var childNodes = child.childNodes; - - var uid; - _forEach(childNodes, function(node) { - if (node.nodeName === 'id') { - uid = child.nodeName + node.innerHTML; - } - }); - - if (options.cache && _entityCache[uid]) { - return null; - } - return parser(child, uid); - } - } - utilIdleWorker(children, parseChild, callback); -} - -export default { - - init: function() { - if (!_notesCache) { - this.reset(); - } - - this.event = utilRebind(this, dispatch, 'on'); - }, - - reset: function() { - var cache = _notesCache; - - if (cache) { - if (cache.notes && cache.notes.inflight) { - _forEach(cache.notes.inflight, abortRequest); - } - } - - _notesCache = { notes: { inflight: {}, loaded: {}, rtree: rbush() } }; - }, - - loadFromAPI: function(path, callback, options) { - options = _extend({ cache: true }, options); - - function done(err, xml) { - if (err) { - callback(err, xml); - } - parse( - xml, - function(entities) { - if (options.cache) { - for (var i in entities) { - _entityCache[entities[i].id] = true; - } - } - callback(null, entities); - }, - options - ); - } - - if (authenticated()) { - return oauth.xhr({ method: 'GET', path: path }, done); - } else { - return d3_xml(path).get(done); - } - }, - - // TODO: refactor /services for consistency by splitting or joining loadTiles & loadTile - loadTile: function(which, currZoom, url, tile) { - var that = this; - var cache = _notesCache[which]; - var bbox = tile.extent.toParam(); - var fullUrl = url + bbox; - - var id = tile.id; - - if (cache.loaded[id] || cache.inflight[id]) return; - - if (_isEmpty(cache.inflight)) { - dispatch.call('loading'); - } - - cache.inflight[id] = that.loadFromAPI( - fullUrl, - function (err, parsed) { - delete cache.inflight[id]; - if (!err) { - cache.loaded[id] = true; - } - - cache.rtree.load(parsed); - - if (_isEmpty(cache.inflight)) { - dispatch.call('loadedNotes'); - } - }, - [] - ); - }, - - loadTiles: function(which, url, projection) { - var that = this; - var s = projection.scale() * 2 * Math.PI, - currZoom = Math.floor(Math.max(Math.log(s) / Math.log(2) - 8, 0)); - - var tiles = getTiles(projection); - - _filter(which.inflight, function(v, k) { - var wanted = _find(tiles, function(tile) { return k === (tile.id + ',0'); }); - if (!wanted) delete which.inflight[k]; - return !wanted; - }).map(abortRequest); - - tiles.forEach(function(tile) { - that.loadTile(which, currZoom, url, tile); - }); - }, - - loadNotes: function(projection) { - var that = this; - var url = urlroot + '/api/0.6/notes?bbox='; - that.loadTiles('notes', url, projection); - }, - - 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 _notesCache.notes.rtree.search(bbox) - .map(function(d) { return d.data; }); - }, - - cache: function() { - return _notesCache; - } -}; \ No newline at end of file diff --git a/test/index.html b/test/index.html index e31966fd8..188b9084c 100644 --- a/test/index.html +++ b/test/index.html @@ -104,7 +104,6 @@ - diff --git a/test/spec/services/notes.js b/test/spec/services/notes.js deleted file mode 100644 index 72d342a0b..000000000 --- a/test/spec/services/notes.js +++ /dev/null @@ -1,90 +0,0 @@ -describe('iD.serviceNotes', function () { - var dimensions = [64, 64], - context, server, notes; - - - before(function() { - iD.services.notes = iD.serviceNotes; - }); - - after(function() { - delete iD.services.notes; - }); - - beforeEach(function() { - context = iD.Context().assetPath('../dist/'); - context.projection - .scale(667544.214430109) // z14 - .translate([-116508, 0]) // 10,0 - .clipExtent([[0,0], dimensions]); - - server = sinon.fakeServer.create(); - notes = iD.services.notes; - notes.reset(); - }); - - afterEach(function() { - server.restore(); - }); - - describe('#init', function () { - it('Initializes cache one time', function () { - var cache = notes.cache(); - expect(cache).to.have.property('notes'); - - notes.init(); - var cache2 = notes.cache(); - expect(cache).to.equal(cache2); - }); - }); - - describe('#reset', function () { - it('resets cache', function () { - notes.cache.foo = 'bar'; - notes.reset(); - expect(notes.cache()).to.not.have.property('foo'); - }); - }); - - describe('#loadFromAPI', function () { - var path = '/api/0.6/notes?bbox=-0.65094,51.312159,0.374908,51.3125', - response = '' + - '' + - '' + - '814798' + - 'https://api.openstreetmap.org/api/0.6/notes/814798' + - 'https://api.openstreetmap.org/api/0.6/notes/814798/comment' + - 'https://api.openstreetmap.org/api/0.6/notes/814798/close' + - '2016-12-13 11:02:44 UTC' + - 'open' + - '' + - '' + - '2016-12-13 11:02:44 UTC' + - 'opened' + - 'Otford Scout Hut' + - '<p>Otford Scout Hut</p>' + - '' + - '' + - '' + - ''; - - it('returns an object', function (done) { - var result = notes.loadFromAPI( - 'https://www.openstreetmap.org' + path, - function (err, xml) { - expect(err).to.not.be.ok; - expect(typeof xml).to.eql('object'); - done(); - }, - []); - - // TODO: clarify why this throws an error - // server.respondWith('GET', 'http://www.openstreetmap.org' + path, - // [200, { 'Content-Type': 'text/xml' }, response]); - // server.respond(); - - done(); - }); - }); - -}); \ No newline at end of file diff --git a/test/spec/services/osm.js b/test/spec/services/osm.js index 3f51916f1..9e2832b92 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 = '' + '' + ' ' + @@ -293,11 +293,58 @@ describe('iD.serviceOsm', function () { }); + + describe('#loadNotes', function () { + var path = '/api/0.6/notes?bbox=-0.65094,51.312159,0.374908,51.3125'; + var response = '' + + '' + + '' + + '814798' + + 'https://api.openstreetmap.org/api/0.6/notes/814798' + + 'https://api.openstreetmap.org/api/0.6/notes/814798/comment' + + 'https://api.openstreetmap.org/api/0.6/notes/814798/close' + + '2016-12-13 11:02:44 UTC' + + 'open' + + '' + + '' + + '2016-12-13 11:02:44 UTC' + + 'opened' + + 'Otford Scout Hut' + + '<p>Otford Scout Hut</p>' + + '' + + '' + + '' + + ''; + + beforeEach(function() { + connection.reset(); + server = sinon.fakeServer.create(); + spy = sinon.spy(); + }); + + afterEach(function() { + server.restore(); + }); + + it('returns an object', function (done) { + connection.loadFromAPI(path, function (err, xml) { + expect(err).to.not.be.ok; + expect(typeof xml).to.eql('object'); + done(); + }); + + server.respondWith('GET', 'http://www.openstreetmap.org' + path, + [200, { 'Content-Type': 'text/xml' }, response]); + server.respond(); + }); + }); + + describe('#loadEntity', function () { var nodeXML = '' + '' + - '', - wayXML = '' + + ''; + var wayXML = '' + '' + '' + ''; @@ -355,11 +402,12 @@ describe('iD.serviceOsm', function () { }); }); + describe('#loadEntityVersion', function () { var nodeXML = '' + '' + - '', - wayXML = '' + + ''; + var wayXML = '' + '' + ''; @@ -416,6 +464,7 @@ describe('iD.serviceOsm', function () { }); }); + describe('#loadMultiple', function () { beforeEach(function() { server = sinon.fakeServer.create(); @@ -428,7 +477,6 @@ describe('iD.serviceOsm', function () { it('loads nodes'); it('loads ways'); it('does not ignore repeat requests'); - }); From 65b2a4226170a43f30c2f5a7efc67c2d838236a3 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sun, 1 Jul 2018 23:39:42 -0400 Subject: [PATCH 27/77] Adjust green --- css/65_data.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/css/65_data.css b/css/65_data.css index b66b48c11..49340654f 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -15,7 +15,7 @@ color: #ff3300; } .layer-notes .note.closed .note-fill { - color: #00bb33; + color: #55dd00; } .layer-notes .note.hovered .note-fill { color: #eebb00; From 13a30c050fc8abdfb2a1c8fd5d670725584194d3 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 00:00:06 -0400 Subject: [PATCH 28/77] Add some dots if there is a comment thread --- modules/svg/notes.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/modules/svg/notes.js b/modules/svg/notes.js index f0f77dcee..c1c261dcb 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -129,6 +129,18 @@ export function svgNotes(projection, context, dispatch) { .attr('y', '-22px') .attr('xlink:href', '#fas-comment-alt'); + // add dots if there's a comment thread + notesEnter.selectAll('.thread') + .data(function(d) { return d.comments.length > 1 ? [0] : []; }) + .enter() + .append('use') + .attr('class', 'note-shadow thread') + .attr('width', '18px') + .attr('height', '18px') + .attr('x', '-9px') + .attr('y', '-22px') + .attr('xlink:href', '#iD-icon-more'); + // update notes .merge(notesEnter) From 3304cc8f5bef977876aa35625597f7e86fb3b76d Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 00:10:21 -0400 Subject: [PATCH 29/77] Remove unused far-comment-alt icon --- build_data.js | 3 +-- svg/fontawesome/far-comment-alt.svg | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 svg/fontawesome/far-comment-alt.svg diff --git a/build_data.js b/build_data.js index f3a21dca3..5fc2c5dab 100644 --- a/build_data.js +++ b/build_data.js @@ -62,8 +62,7 @@ module.exports = function buildData() { // Font Awesome icons used var faIcons = { - 'fas-comment-alt': {}, - 'far-comment-alt': {} + 'fas-comment-alt': {} }; // Start clean diff --git a/svg/fontawesome/far-comment-alt.svg b/svg/fontawesome/far-comment-alt.svg deleted file mode 100644 index 42b32cd8d..000000000 --- a/svg/fontawesome/far-comment-alt.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 2c22fe00a2304db7309b64f7eff104c90642c71d Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 2 Jul 2018 10:44:47 -0400 Subject: [PATCH 30/77] updated: hacky note hovering; todo: complete note click handling --- modules/behavior/hover.js | 17 ++++++++--------- modules/behavior/select.js | 14 +++++++++++--- modules/svg/notes.js | 37 +++---------------------------------- modules/ui/sidebar.js | 36 ++++++++++++++++-------------------- 4 files changed, 38 insertions(+), 66 deletions(-) diff --git a/modules/behavior/hover.js b/modules/behavior/hover.js index 1a1a9ee67..d086ac72c 100644 --- a/modules/behavior/hover.js +++ b/modules/behavior/hover.js @@ -111,14 +111,9 @@ export function behaviorHover(context) { .classed('hover-suppressed', false); var entity; - if (datum instanceof osmEntity) { + if (datum instanceof osmNote || datum instanceof osmEntity) { entity = datum; - } - // TODO: TAH - reintroduce if we need a check for osmNote here - // else if (datum instanceof osmNote) { - // entity = datum; - // } - else { + } else { entity = datum && datum.properties && datum.properties.entity; } @@ -130,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) { @@ -143,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 9a9c0b34d..a3f85b203 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -13,7 +13,10 @@ import { modeSelect } from '../modes'; -import { osmEntity } from '../osm'; +import { + osmEntity, + osmNote +} from '../osm'; export function behaviorSelect(context) { @@ -115,14 +118,19 @@ export function behaviorSelect(context) { var datum = d3_event.target.__data__ || (lastMouse && lastMouse.target.__data__); var mode = context.mode(); - var entity = datum && datum.properties && datum.properties.entity; + var entity; + + // check if datum is a note + if (datum instanceof osmNote) { entity = datum; } + else { entity = datum && datum.properties && datum.properties.entity; } + if (entity) datum = entity; if (datum && datum.type === 'midpoint') { datum = datum.parents[0]; } - if (!(datum instanceof osmEntity)) { + if (!(datum instanceof osmEntity) && !(datum instanceof osmNote)) { // clicked nothing.. if (!isMultiselect && mode.id !== 'browse') { context.enter(modeBrowse(context)); diff --git a/modules/svg/notes.js b/modules/svg/notes.js index c1c261dcb..bead738fd 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -3,6 +3,8 @@ import { select as d3_select } from 'd3-selection'; import { svgPointTransform } from './index'; import { services } from '../services'; +import { osmNote } from '../osm'; + import { uiNoteEditor } from '../ui'; export function svgNotes(projection, context, dispatch) { @@ -62,36 +64,6 @@ export function svgNotes(projection, context, dispatch) { .on('end', editOff); } - - function click(which) { - _selected = which; - context.map().centerEase(which.loc); - - layer.selectAll('.note') - .classed('selected', function(d) { return d === _selected; }); - - context.ui().sidebar.show(noteEditor, which); - } - - - function mouseover(which) { - layer.selectAll('.note') - .classed('hovered', function(d) { return d === which; }); - - context.ui().sidebar.show(noteEditor, which); - } - - - function mouseout() { - layer.selectAll('.note') - .classed('hovered', false); - - // TODO: check if the item was clicked. If so, it should remain on the sidebar. - // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js - context.ui().sidebar.hide(); - } - - function update() { var service = getService(); var data = (service ? service.notes(projection) : []); @@ -106,10 +78,7 @@ export function svgNotes(projection, context, dispatch) { // enter var notesEnter = notes.enter() .append('g') - .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }) - .on('click', click) - .on('mouseover', mouseover) - .on('mouseout', mouseout); + .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }); notesEnter .append('use') diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 8782097d4..9f40fbb60 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -3,24 +3,16 @@ import { uiFeatureList } from './feature_list'; import { uiInspector } from './inspector'; import { uiNoteEditor } from './note_editor'; +import { + osmNote +} from '../osm'; + export function uiSidebar(context) { var inspector = uiInspector(context), noteEditor = uiNoteEditor(context), current, - wasNote; - - function isNote(id) { - var isNote = (id && id.slice(0,4) === 'note') ? id.slice(0,4) : null; - // TODO: have a better check, perhaps see if the hover class is activated on a note - if (!isNote && wasNote) { - wasNote = false; - sidebar.hide(); - } else if (isNote) { - wasNote = true; - sidebar.show(noteEditor); - } - } + wasNote = false; function sidebar(selection) { var featureListWrap = selection @@ -34,10 +26,12 @@ export function uiSidebar(context) { .attr('class', 'inspector-hidden inspector-wrap fr'); - function hover(id) { - // isNote(id); TODO: instantiate check if needed - - if (!current && context.hasEntity(id)) { + function hover(what) { + if ((what instanceof osmNote)) { + wasNote = true; + context.ui().sidebar.show(noteEditor, what); + } + else if (!current && context.hasEntity(what)) { featureListWrap .classed('inspector-hidden', true); @@ -45,10 +39,10 @@ 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); @@ -61,8 +55,10 @@ export function uiSidebar(context) { .classed('inspector-hidden', true); inspector .state('hide'); + } else if (wasNote) { + wasNote = false; + context.ui().sidebar.hide(); } - // } // TODO: - remove if note check logic is moved } From a474e3bb9f9d8f97a7b7cfa4a247a6dbd0c3d5d1 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 12:32:40 -0400 Subject: [PATCH 31/77] Working on note editor style and ui, simplify d3 code --- css/65_data.css | 19 ++--- data/core.yaml | 19 ++--- dist/locales/en.json | 5 +- modules/svg/notes.js | 30 ++++++++ modules/ui/note_editor.js | 152 +++++++++++++------------------------- 5 files changed, 96 insertions(+), 129 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 49340654f..ef0c01b15 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -25,21 +25,16 @@ } /* OSM Note UI */ -.comment-first { - background-color: #ddd; - border-radius: 5px; - padding: 5px; - margin: 5px auto; -} .comment { background-color: #fff; border-radius: 5px; - padding: 5px; - margin: 5px auto; -} -.commentCreator { - color: #666; + padding: 10px; + margin: 10px auto; } .commentText { - margin: 20px auto; + margin-bottom: 15px; + color: #333; +} +.commentCreator { + color: #aaa; } diff --git a/data/core.yaml b/data/core.yaml index 731d9a99a..8c077dac0 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -611,18 +611,15 @@ en: openstreetcam: view_on_openstreetcam: "View this image on OpenStreetCam" note: - title: "Edit note" - unresolved: "Unresolved note #" - description: "Description" - creator: "Comment from" - anonymous: 'anonymous' - creatorOn: 'on' - commentTitle: 'Comments' - resolve: "Resolve" - comment: "Comment" + note: Note + title: Edit note + anonymous: anonymous + commentTitle: Comments + resolve: Resolve + comment: Comment commentResolve: "Comment & Resolve" - save: "Save new note" - cancel: "Cancel" + save: Save new note + cancel: Cancel help: title: Help key: H diff --git a/dist/locales/en.json b/dist/locales/en.json index dc650c6b4..6c4d4e8d5 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -744,12 +744,9 @@ "view_on_openstreetcam": "View this image on OpenStreetCam" }, "note": { + "note": "Note", "title": "Edit note", - "unresolved": "Unresolved note #", - "description": "Description", - "creator": "Comment from", "anonymous": "anonymous", - "creatorOn": "on", "commentTitle": "Comments", "resolve": "Resolve", "comment": "Comment", diff --git a/modules/svg/notes.js b/modules/svg/notes.js index bead738fd..6cc9b47f9 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -64,6 +64,36 @@ export function svgNotes(projection, context, dispatch) { .on('end', editOff); } + + function click(which) { + _selected = which; + context.map().centerEase(which.loc); + + layer.selectAll('.note') + .classed('selected', function(d) { return d === _selected; }); + + // context.ui().sidebar.show(noteEditor.note(which)); + } + + + function mouseover(which) { + layer.selectAll('.note') + .classed('hovered', function(d) { return d === which; }); + + // context.ui().sidebar.show(noteEditor.note(which)); + } + + + function mouseout() { + layer.selectAll('.note') + .classed('hovered', false); + + // TODO: check if the item was clicked. If so, it should remain on the sidebar. + // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js + // context.ui().sidebar.hide(); + } + + function update() { var service = getService(); var data = (service ? service.notes(projection) : []); diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 821dfd02c..41b38410c 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -4,6 +4,7 @@ import { uiFormFields } from './form_fields'; import { uiField } from './field'; import { utilRebind } from '../util'; +import { utilDetect } from '../util/detect'; import { t } from '../util/locale'; @@ -11,117 +12,68 @@ export function uiNoteEditor(context) { var dispatch = d3_dispatch('change'); var formFields = uiFormFields(context); var _fieldsArr; - var _noteID; + var _note; - function noteEditor(selection, note) { - render(selection, note); + + 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); } - function parseNoteUnresolved(selection, note) { - var unresolved = selection.selectAll('.noteUnresolved') - .data(note, function(d) { return d.id; }) + function noteEditor(selection) { + render(selection); + } + + + function noteHeader(selection) { + selection.selectAll('.note-header') + .data(_note, function(d) { return d.id; }) .enter() .append('h3') - .attr('class', 'noteUnresolved') - .text(function(d) { return String(t('note.unresolved') + ' ' + d.id); }); - - selection.merge(unresolved); - return selection; + .attr('class', 'note-header') + .text(function(d) { return String(t('note.note') + ' ' + d.id); }); } - function parseNoteComments(selection, note) { + function noteComments(selection) { function noteCreator(d) { var userName = d.user ? d.user : t('note.anonymous'); - return String(t('note.creator') + ' ' + userName + ' ' + t('note.creatorOn') + ' ' + d.date); + return String(userName + ' ' + localeDateString(d.date)); } - var comments = selection - .append('div') - .attr('class', 'comments'); + var comments = selection.selectAll('.comments') + .data([0]); - var comment = comments.selectAll('.comment') - .data(note.comments, function(d) { return d.uid; }) + comments = comments.enter() + .append('div') + .attr('class', 'comments') + .merge(comments); + + var commentEnter = comments.selectAll('.comment') + .data(_note.comments, function(d) { return d.uid; }) .enter() .append('div') .attr('class', 'comment'); - // append the creator - comment - .append('p') - .attr('class', 'commentCreator') - .text(function(d) { return noteCreator(d); }); - - // append the comment - comment + commentEnter .append('p') .attr('class', 'commentText') .text(function(d) { return d.text; }); - comments.insert('h4', ':first-child') - .text(t('note.description')); + commentEnter + .append('p') + .attr('class', 'commentCreator') + .text(function(d) { return noteCreator(d); }); - // TODO: have a better check to highlight the first/author comment (e.g., check if `author: true`) - comments.select('div') - .attr('class', 'comment-first'); - - - selection.merge(comments); - return selection; } - function render(selection, note) { - - var exampleNote = { - close_url: 'example_close_url', - comment_url: 'example_comment_url', - comments: [ - { - action: 'opened', - date: '2016-11-20 00:50:20 UTC', - html: '<p>Test comment1.</p>', - text: 'Test comment1', - uid: '111111', - user: 'User1', - user_url: 'example_user_url1' - }, - { - action: 'opened', - date: '2016-11-20 00:50:20 UTC', - html: '<p>Test comment2.</p>', - text: 'Test comment2', - uid: '222222', - user: 'User2', - user_url: 'example_user_url2' - }, - { - action: 'opened', - date: '2016-11-20 00:50:20 UTC', - html: '<p>Test comment3.</p>', - text: 'Test comment3', - uid: '333333', - user: 'User3', - user_url: 'example_user_url3' - } - ], - date_created: '2016-11-20 00:50:20 UTC', - id: 'note789148', - loc: [ - -120.0219036, - 34.4611879 - ], - status: 'open', - type: 'note', - url: 'https://api.openstreetmap.org/api/0.6/notes/789148', - visible: true - }; - - var currentNote = note ? [note] : [exampleNote]; - - var author = currentNote[0].comments[0]; - author.author = true; + function render(selection) { var header = selection.selectAll('.header') .data([0]); @@ -131,6 +83,7 @@ export function uiNoteEditor(context) { .append('h3') .text(t('note.title')); + var body = selection.selectAll('.body') .data([0]); @@ -139,28 +92,23 @@ export function uiNoteEditor(context) { .attr('class', 'body') .merge(body); - // Note Section - var noteSection = body.selectAll('.changeset-editor') - .data([0]); - - noteSection = noteSection.enter() + body.selectAll('.note-editor') + .data([0]) + .enter() .append('div') - .attr('class', 'modal-section changeset-editor') - .merge(noteSection); - - noteSection = noteSection.call(parseNoteUnresolved, currentNote); - - noteSection = noteSection.call(parseNoteComments, currentNote[0]); - // TODO: revisit commit.js, changeset_editor.js to get warnings, fields array, button toggles, etc. + .attr('class', 'modal-section note-editor') + .call(noteHeader) + .call(noteComments); } - noteEditor.noteID = function(_) { - if (!arguments.length) return _noteID; - if (_noteID === _) return noteEditor; - _noteID = _; + + noteEditor.note = function(_) { + if (!arguments.length) return _note; + _note = _; _fieldsArr = null; return noteEditor; }; + return utilRebind(noteEditor, dispatch, 'on'); -} \ No newline at end of file +} From 94eae89fc3aafe9287872acefd8776b8545e195f Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 12:50:53 -0400 Subject: [PATCH 32/77] Merge, continue to tweak note_editor --- modules/ui/note_editor.js | 2 +- modules/ui/sidebar.js | 40 +++++++++++++++++++-------------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 41b38410c..e337493fb 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -32,7 +32,7 @@ export function uiNoteEditor(context) { function noteHeader(selection) { selection.selectAll('.note-header') - .data(_note, function(d) { return d.id; }) + .data([_note], function(d) { return d.id; }) .enter() .append('h3') .attr('class', 'note-header') diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 9f40fbb60..ad3d1e155 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -1,18 +1,17 @@ import _throttle from 'lodash-es/throttle'; + +import { osmNote } from '../osm'; import { uiFeatureList } from './feature_list'; import { uiInspector } from './inspector'; import { uiNoteEditor } from './note_editor'; -import { - osmNote -} from '../osm'; export function uiSidebar(context) { + var inspector = uiInspector(context); + var noteEditor = uiNoteEditor(context); + var _current; + var _wasNote = false; - var inspector = uiInspector(context), - noteEditor = uiNoteEditor(context), - current, - wasNote = false; function sidebar(selection) { var featureListWrap = selection @@ -28,10 +27,10 @@ export function uiSidebar(context) { function hover(what) { if ((what instanceof osmNote)) { - wasNote = true; - context.ui().sidebar.show(noteEditor, what); - } - else if (!current && context.hasEntity(what)) { + _wasNote = true; + context.ui().sidebar.show(noteEditor.note(what)); + + } else if (!_current && context.hasEntity(what)) { featureListWrap .classed('inspector-hidden', true); @@ -48,15 +47,16 @@ export function uiSidebar(context) { .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; + + } else if (_wasNote) { + _wasNote = false; context.ui().sidebar.hide(); } } @@ -66,7 +66,7 @@ export function uiSidebar(context) { sidebar.select = function(id, newFeature) { - if (!current && id) { + if (!_current && id) { featureListWrap .classed('inspector-hidden', true); @@ -84,7 +84,7 @@ export function uiSidebar(context) { .call(inspector); } - } else if (!current) { + } else if (!_current) { featureListWrap .classed('inspector-hidden', false); inspectorWrap @@ -101,8 +101,8 @@ export function uiSidebar(context) { 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, element); @@ -115,8 +115,8 @@ export function uiSidebar(context) { inspectorWrap .classed('inspector-hidden', true); - if (current) current.remove(); - current = null; + if (_current) _current.remove(); + _current = null; }; } From 86dc0c9012b8f5eb5611dde344081199f96b640e Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 2 Jul 2018 15:51:55 -0400 Subject: [PATCH 33/77] updated: new comments in notes sidebar ui --- css/65_data.css | 6 ++ data/core.yaml | 6 +- dist/locales/en.json | 4 +- modules/ui/note_editor.js | 133 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index ef0c01b15..788c0e9eb 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -38,3 +38,9 @@ .commentCreator { color: #aaa; } + +/* Note editor UI */ +#new-comment-input { + width: 100%; + height: 100px; +} diff --git a/data/core.yaml b/data/core.yaml index 8c077dac0..5640a8261 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -616,9 +616,11 @@ en: anonymous: anonymous commentTitle: Comments resolve: Resolve + newComment: New Comment + inputPlaceholder: Enter a comment to share with other users. comment: Comment - commentResolve: "Comment & Resolve" - save: Save new note + commentResolve: Comment & Resolve + save: Save comment cancel: Cancel help: title: Help diff --git a/dist/locales/en.json b/dist/locales/en.json index 6c4d4e8d5..0820a7b88 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -749,9 +749,11 @@ "anonymous": "anonymous", "commentTitle": "Comments", "resolve": "Resolve", + "newComment": "New Comment", + "inputPlaceholder": "Enter a comment to share with other users.", "comment": "Comment", "commentResolve": "Comment & Resolve", - "save": "Save new note", + "save": "Save comment", "cancel": "Cancel" }, "help": { diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index e337493fb..9f2f8c073 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -1,20 +1,27 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { uiFormFields } from './form_fields'; +import { select as d3_select } from 'd3-selection'; + +import { t } from '../util/locale'; +import { + utilGetSetValue, + utilNoAuto, + utilRebind +} from '../util'; import { uiField } from './field'; -import { utilRebind } from '../util'; import { utilDetect } from '../util/detect'; -import { t } from '../util/locale'; + +var _newComment; export function uiNoteEditor(context) { - var dispatch = d3_dispatch('change'); + var dispatch = d3_dispatch('change', 'cancel', 'save'); var formFields = uiFormFields(context); var _fieldsArr; var _note; - function localeDateString(s) { if (!s) return null; var detected = utilDetect(); @@ -72,6 +79,121 @@ export function uiNoteEditor(context) { } + function saveHeader(selection) { + var header = selection.selectAll('.notesSaveHeader') + .data([0]); + header = header.enter() + .append('h4') + .attr('class', '.notesSaveHeader') + .text(t('note.newComment')) + .merge(header); + } + + function input(selection) { + + // Input + var input = selection.selectAll('textarea') + .data([0]); + + // enter + input = input.enter() + .append('textarea') + .attr('id', 'new-comment-input') + .attr('placeholder', t('note.inputPlaceholder')) + .attr('maxlength', 1000) + .call(utilNoAuto) + // .on('input', change(true)) + .on('blur', change()) + .on('change', change()) + .merge(input); + + + function change(onInput) { + return function() { + var t = {}; + // t[field.key] = utilGetSetValue(input) || undefined; + dispatch.call('change', this, t, onInput); + }; + } + + // // Input + // var inputSection = selection.selectAll('.note-input') + // .data([0]); + + // // enter + // var inputEnter = inputSection.enter() + // .append('div') + // .attr('class', 'tempClassName'); + + // update + } + + function buttons(selection) { + // Buttons + var buttonSection = selection.selectAll('.buttons') + .data([0]); + + // enter + var buttonEnter = buttonSection.enter() + .append('div') + .attr('class', 'buttons cf'); + + buttonEnter + .append('button') + .attr('class', 'secondary-action col5 button cancel-button') + .append('span') + .attr('class', 'label') + .text(t('note.cancel')); + + buttonEnter + .append('button') + .attr('class', 'action col5 button save-button') + .append('span') + .attr('class', 'label') + .text(t('note.save')); + + // update + buttonSection = buttonSection + .merge(buttonEnter); + + buttonSection.selectAll('.cancel-button') + .on('click.cancel', function() { + // var selectedID = commitChanges.entityID(); TODO: cancel note event + // dispatch.call('cancel', this, selectedID); + }); + + buttonSection.selectAll('.save-button') + .attr('disabled', function() { + var n = d3_select('#new-comment-input').node(); + return (n && n.value.length) ? null : true; + }) + .on('click.save', function() { + this.blur(); // avoid keeping focus on the button - #4641 + // dispatch.call('saveNote', this, _newComment); TODO: saveNote event + }); + } + + function newComment(selection) { + // New Comment + var saveSection = selection.selectAll('.save-section') + .data([0]); + + // saveSection = saveSection.enter() + // .append('h4') + // .text(t('note.newComment')) + // .merge(saveSection); + + saveSection = saveSection.enter() + .append('div') + .attr('class','save-section cf') + .merge(saveSection); + + saveSection + .call(saveHeader) + .call(input) + .call(buttons); + } + function render(selection) { var header = selection.selectAll('.header') @@ -98,7 +220,8 @@ export function uiNoteEditor(context) { .append('div') .attr('class', 'modal-section note-editor') .call(noteHeader) - .call(noteComments); + .call(noteComments) + .call(newComment); } From 7999aca9e2aae889315e6e04470bcb9c074a69b2 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 16:02:22 -0400 Subject: [PATCH 34/77] Add getNode and replaceNote methods, refactor caches --- modules/services/osm.js | 105 ++++++++++++++++++++++------------------ 1 file changed, 57 insertions(+), 48 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index d0a9b933b..87a6ebfae 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -40,10 +40,9 @@ var oauth = osmAuth({ }); var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; -var _tileCache = { loaded: {}, inflight: {} }; -var _noteCache = { loaded: {}, inflight: {}, rtree: rbush() }; +var _tileCache = { loaded: {}, inflight: {}, seen: {} }; +var _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; var _changeset = {}; -var _seenEntity = {}; var _connectionID = 1; var _tileZoom = 16; @@ -191,61 +190,60 @@ var parsers = { note: function parseNote(obj, uid) { var attrs = obj.attributes; var childNodes = obj.childNodes; - var parsedNote = {}; + var props = {}; - parsedNote.loc = getLoc(attrs); + props.id = uid; + props.loc = getLoc(attrs); _forEach(childNodes, function(node) { if (node.nodeName !== '#text') { var nodeName = node.nodeName; // if the element is comments, parse the comments if (nodeName === 'comments') { - parsedNote[nodeName] = parseComments(node.childNodes); + props[nodeName] = parseComments(node.childNodes); } else { - parsedNote[nodeName] = node.innerHTML; + props[nodeName] = node.innerHTML; } } }); - parsedNote.id = uid; - parsedNote.type = 'note'; - - return { - minX: parsedNote.loc[0], - minY: parsedNote.loc[1], - maxX: parsedNote.loc[0], - maxY: parsedNote.loc[1], - data: new osmNote(parsedNote) - }; + return new osmNote(props); } }; -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) return; + if (!parser) return null; var uid; if (child.nodeName === 'note') { uid = child.getElementsByTagName('id')[0].textContent; } else { uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); - if (options.cache && _seenEntity[uid]) { - return null; // avoid reparsing a "seen" entity + 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); } @@ -266,10 +264,9 @@ export default { _forEach(_noteCache.inflight, abortRequest); if (_changeset.inflight) abortRequest(_changeset.inflight); - _tileCache = { loaded: {}, inflight: {} }; - _noteCache = { loaded: {}, inflight: {}, rtree: rbush() }; + _tileCache = { loaded: {}, inflight: {}, seen: {} }; + _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; _changeset = {}; - _seenEntity = {}; return this; }, @@ -310,7 +307,7 @@ export default { loadFromAPI: function(path, callback, options) { - options = _extend({ cache: true }, options); + options = _extend({ skipSeen: true }, options); var that = this; var cid = _connectionID; @@ -339,15 +336,11 @@ export default { } if (callback) { - if (err) return callback(err, null); - parse(xml, function (entities) { - if (options.cache) { - for (var i in entities) { - _seenEntity[entities[i].id] = true; // avoid re-parsing again later - } - } - callback(null, entities); - }, options); + if (err) { + return callback(err); + } else { + return parseXML(xml, callback, options); + } } } } @@ -364,7 +357,7 @@ export default { 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' : ''), @@ -379,7 +372,7 @@ export default { 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, @@ -397,7 +390,7 @@ export default { _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( @@ -701,23 +694,26 @@ export default { // issue new requests.. tiles.forEach(function(tile) { - var id = tile.id; - if (cache.loaded[id] || cache.inflight[id]) return; + if (cache.loaded[tile.id] || cache.inflight[tile.id]) return; if (!loadingNotes && _isEmpty(cache.inflight)) { dispatch.call('loading'); // start the spinner } - var options = { cache: !loadingNotes }; - cache.inflight[id] = that.loadFromAPI( + var options = { skipSeen: !loadingNotes }; + cache.inflight[tile.id] = that.loadFromAPI( path + tile.extent.toParam(), function(err, parsed) { - delete cache.inflight[id]; + delete cache.inflight[tile.id]; if (!err) { - cache.loaded[id] = true; + cache.loaded[tile.id] = true; } if (loadingNotes) { - cache.rtree.load(parsed); + var notes = parsed.map(function(d) { + cache.note[d.id] = d; + return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; + }); + cache.rtree.load(notes); dispatch.call('loadedNotes'); } else { if (callback) { @@ -813,6 +809,19 @@ export default { }, + getNote: function(id) { + return _noteCache.note[id]; + }, + + + replaceNote: function(n) { + if (n instanceof osmNote) { + _noteCache.note[n.id] = n; + } + return n; + }, + + loadedNotes: function(_) { if (!arguments.length) return _noteCache.loaded; _noteCache.loaded = _; From bd8705bf85a678e63f9ddb1d1c5144844aeedfea Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 2 Jul 2018 16:32:17 -0400 Subject: [PATCH 35/77] updated: reintroduced hovering on notes --- modules/ui/sidebar.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index ad3d1e155..509d2c1c6 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -1,5 +1,7 @@ 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'; @@ -11,6 +13,7 @@ export function uiSidebar(context) { var noteEditor = uiNoteEditor(context); var _current; var _wasNote = false; + // var layer = d3_select(null); function sidebar(selection) { @@ -28,6 +31,9 @@ export function uiSidebar(context) { function hover(what) { if ((what instanceof osmNote)) { _wasNote = true; + var notes = d3_selectAll('.note'); + notes + .classed('hovered', function(d) { return d === what; }); context.ui().sidebar.show(noteEditor.note(what)); } else if (!_current && context.hasEntity(what)) { @@ -57,6 +63,8 @@ export function uiSidebar(context) { } else if (_wasNote) { _wasNote = false; + d3_selectAll('.note') + .classed('hovered', false); context.ui().sidebar.hide(); } } From 2d2845e5d4c842b77332c0b774c7ac77d7a445ba Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 16:55:55 -0400 Subject: [PATCH 36/77] If notes are coincident, move them apart slightly --- modules/services/osm.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 87a6ebfae..dd2793523 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -16,7 +16,7 @@ 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, @@ -195,6 +195,18 @@ var parsers = { 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 _forEach(childNodes, function(node) { if (node.nodeName !== '#text') { var nodeName = node.nodeName; @@ -207,7 +219,10 @@ var parsers = { } }); - return new osmNote(props); + 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); + return note; } }; From d87a2c2d2d5db2dbd479aede47ad1b6848078f91 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 22:18:42 -0400 Subject: [PATCH 37/77] Adjust pointer css for note markers This sets `pointer-events: none` for the `.note-shadow` class, to fix an amusing bug where hovering over the filled part of the comment icon would work ok but hovering over the "dots" would not. --- css/65_data.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 788c0e9eb..0b4db4d47 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -3,16 +3,16 @@ .layer-notes { pointer-events: none; } -.layer-notes * { - pointer-events: visible; - cursor: pointer; -} .layer-notes .note .note-shadow { color: #000; + pointer-events: none; } .layer-notes .note .note-fill { color: #ff3300; + pointer-events: visible; + cursor: pointer; /* Opera */ + cursor: url(img/cursor-select-point.png), pointer; /* FF */ } .layer-notes .note.closed .note-fill { color: #55dd00; From e0cc6260f50d7e9928a58cb9a5f96ef59c24e465 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 2 Jul 2018 23:22:00 -0400 Subject: [PATCH 38/77] Switch `innerHtml` to `textContent`, which properly unescapes xml This commit also fixes a linter error, and switches some lodash _forEach to normal for loops. --- modules/services/osm.js | 44 ++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index dd2793523..2d599faee 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -123,20 +123,23 @@ function parseComments(comments) { var parsedComments = []; // for each comment - _forEach(comments, function(comment) { + for (var i = 0; i < comments.length; i++) { + var comment = comments[i]; if (comment.nodeName === 'comment') { var childNodes = comment.childNodes; var parsedComment = {}; - _forEach(childNodes, function(node) { - if (node.nodeName !== '#text') { - var nodeName = node.nodeName; - parsedComment[nodeName] = node.innerHTML; - } - }); - if (parsedComment) { parsedComments.push(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 (parsedComment) { + parsedComments.push(parsedComment); + } } - }); + } return parsedComments; } @@ -204,20 +207,21 @@ var parsers = { } var bbox = geoExtent(props.loc).bbox(); coincident = _noteCache.rtree.search(bbox).length; - } while(coincident); + } while (coincident); // parse note contents - _forEach(childNodes, function(node) { - if (node.nodeName !== '#text') { - var nodeName = node.nodeName; - // if the element is comments, parse the comments - if (nodeName === 'comments') { - props[nodeName] = parseComments(node.childNodes); - } else { - props[nodeName] = node.innerHTML; - } + 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 }; From ab8e793a7226f70dfae0e787e598bb8503bcf0ce Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 3 Jul 2018 02:41:47 -0400 Subject: [PATCH 39/77] Playing with note styling, add avatars and more metadata --- css/65_data.css | 35 +++++++++++++++++++++++++--- modules/ui/note_editor.js | 48 ++++++++++++++++++++++++++------------- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 0b4db4d47..93627877e 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -28,16 +28,45 @@ .comment { background-color: #fff; border-radius: 5px; + border: 1px solid #ccc; padding: 10px; margin: 10px auto; + display: flex; + flex-flow: row nowrap; } -.commentText { - margin-bottom: 15px; + +.comment-main { + padding: 0 10px; + flex: 1 1 100%; + flex-flow: column nowrap; + overflow: hidden; + overflow-wrap: break-word; +} +.comment-avatar { + flex: 0 0 40px; +} +.comment-avatar .icon.comment-avatar-icon { + width: 40px; + height: 40px; + border: 1px solid #ccc; + border-radius: 20px; +} +.comment-metadata { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; +} +.comment-author { + font-weight: bold; color: #333; } -.commentCreator { +.comment-date { color: #aaa; } +.comment-text { + color: #333; + margin-top: 10px; +} /* Note editor UI */ #new-comment-input { diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 9f2f8c073..b6b772a9f 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -4,6 +4,7 @@ import { uiFormFields } from './form_fields'; import { select as d3_select } from 'd3-selection'; import { t } from '../util/locale'; +import { svgIcon } from '../svg'; import { utilGetSetValue, utilNoAuto, @@ -48,11 +49,6 @@ export function uiNoteEditor(context) { function noteComments(selection) { - function noteCreator(d) { - var userName = d.user ? d.user : t('note.anonymous'); - return String(userName + ' ' + localeDateString(d.date)); - } - var comments = selection.selectAll('.comments') .data([0]); @@ -62,23 +58,43 @@ export function uiNoteEditor(context) { .merge(comments); var commentEnter = comments.selectAll('.comment') - .data(_note.comments, function(d) { return d.uid; }) + .data(_note.comments) .enter() .append('div') .attr('class', 'comment'); - commentEnter - .append('p') - .attr('class', 'commentText') + var avatar = commentEnter + .append('div') + .attr('class', 'comment-avatar'); + + avatar + .call(svgIcon('#iD-icon-avatar', 'comment-avatar-icon')); + + var main = commentEnter + .append('div') + .attr('class', 'comment-main'); + + var meta = main + .append('div') + .attr('class', 'comment-metadata'); + + meta + .append('div') + .attr('class', 'comment-author') + .text(function(d) { return d.user || t('note.anonymous'); }); + + meta + .append('div') + .attr('class', 'comment-date') + .text(function(d) { return d.action + ' ' + localeDateString(d.date); }); + + main + .append('div') + .attr('class', 'comment-text') .text(function(d) { return d.text; }); - - commentEnter - .append('p') - .attr('class', 'commentCreator') - .text(function(d) { return noteCreator(d); }); - } + function saveHeader(selection) { var header = selection.selectAll('.notesSaveHeader') .data([0]); @@ -89,8 +105,8 @@ export function uiNoteEditor(context) { .merge(header); } - function input(selection) { + function input(selection) { // Input var input = selection.selectAll('textarea') .data([0]); From 61ae541cfc063d9cb06cd6d3dbd93965a982104f Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 3 Jul 2018 10:57:53 -0400 Subject: [PATCH 40/77] updated: notes save buttons --- css/65_data.css | 1 - css/80_app.css | 10 ++++++++-- data/core.yaml | 3 ++- dist/locales/en.json | 3 ++- modules/ui/commit.js | 4 ++-- modules/ui/note_editor.js | 32 +++++++++++++++++++++++++------- 6 files changed, 39 insertions(+), 14 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 93627877e..fa1a18ba3 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -68,7 +68,6 @@ margin-top: 10px; } -/* Note editor UI */ #new-comment-input { width: 100%; height: 100px; diff --git a/css/80_app.css b/css/80_app.css index 46eaa1da2..f6ca2de6b 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -3566,10 +3566,16 @@ img.tile-debug { vertical-align: middle; } +.save-section .buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-around; +} + .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 5640a8261..2aa605657 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -615,7 +615,8 @@ en: title: Edit note anonymous: anonymous commentTitle: Comments - resolve: Resolve + close: Resolve + reopen: Reopen newComment: New Comment inputPlaceholder: Enter a comment to share with other users. comment: Comment diff --git a/dist/locales/en.json b/dist/locales/en.json index 0820a7b88..b87f22d0a 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -748,7 +748,8 @@ "title": "Edit note", "anonymous": "anonymous", "commentTitle": "Comments", - "resolve": "Resolve", + "close": "Resolve", + "reopen": "Reopen", "newComment": "New Comment", "inputPlaceholder": "Enter a comment to share with other users.", "comment": "Comment", diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 3b934898c..ec79e7baf 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/note_editor.js b/modules/ui/note_editor.js index b6b772a9f..852a4232e 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -156,22 +156,45 @@ export function uiNoteEditor(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('note.cancel')); buttonEnter .append('button') - .attr('class', 'action col5 button save-button') + .attr('class', 'action button save-button') .append('span') .attr('class', 'label') .text(t('note.save')); + var status; + if (_note.status) { + status = _note.status === 'open' ? t('note.close') : t('note.reopen'); + } + + buttonEnter + .append('button') + .attr('class', _note.status + '-button status-button action button') + .append('span') + .attr('class', 'label') + .text(status); + + // update buttonSection = buttonSection .merge(buttonEnter); + buttonSection.selectAll('.close-button') + .on('click.close', function() { + console.log('close button clicked'); + }); + + buttonSection.selectAll('.reopen-button') + .on('click.reopen', function() { + console.log('reopen button clicked'); + }); + buttonSection.selectAll('.cancel-button') .on('click.cancel', function() { // var selectedID = commitChanges.entityID(); TODO: cancel note event @@ -194,11 +217,6 @@ export function uiNoteEditor(context) { var saveSection = selection.selectAll('.save-section') .data([0]); - // saveSection = saveSection.enter() - // .append('h4') - // .text(t('note.newComment')) - // .merge(saveSection); - saveSection = saveSection.enter() .append('div') .attr('class','save-section cf') From 8121f585d59aa7758280e0cc3c4a49d233f2653f Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 3 Jul 2018 15:39:22 -0400 Subject: [PATCH 41/77] added checks for multiclick and click on notes --- modules/behavior/select.js | 6 +- modules/modes/select.js | 170 ++++++++++++++++++++----------------- modules/ui/note_editor.js | 13 +-- 3 files changed, 98 insertions(+), 91 deletions(-) diff --git a/modules/behavior/select.js b/modules/behavior/select.js index a3f85b203..a0203c5ee 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -121,7 +121,11 @@ export function behaviorSelect(context) { var entity; // check if datum is a note - if (datum instanceof osmNote) { entity = datum; } + if (datum instanceof osmNote) { + if (!isMultiselect) entity = datum; + else { entity = 'multiselectedNote'; } // if multiselected, ignore notes TODO: possibly give warning + } + else { entity = datum && datum.properties && datum.properties.entity; } if (entity) datum = entity; diff --git a/modules/modes/select.js b/modules/modes/select.js index 1cb2b0002..eeb7cfb66 100644 --- a/modules/modes/select.js +++ b/modules/modes/select.js @@ -34,10 +34,12 @@ import { osmWay } from '../osm'; +import { serviceOsm } from '../services'; + import { modeBrowse } from './browse'; import { modeDragNode } from './drag_node'; import * as Operations from '../operations/index'; -import { uiEditMenu, uiSelectionList } from '../ui'; +import { uiEditMenu, uiSelectionList, uiNoteEditor } from '../ui'; import { uiCmd } from '../ui/cmd'; import { utilEntityOrMemberSelector, utilEntitySelector } from '../util'; @@ -70,6 +72,7 @@ export function modeSelect(context, selectedIDs) { var newFeature = false; var suppressMenu = true; var follow = false; + var noteEditor = uiNoteEditor(context); var wrap = context.container() @@ -427,93 +430,104 @@ export function modeSelect(context, selectedIDs) { } } + var noteFound; + if (!checkSelectedIDs()) { + // check if any selectedIDs are within the loaded notes + var notes = serviceOsm.notes(context.projection); + var noteIDs = _map(notes, function(note) { return note.id; }); + noteFound = noteIDs.some(function(note) { + return selectedIDs.includes(note); + }); + if (!noteFound) return; + } - if (!checkSelectedIDs()) return; - - var operations = _without(_values(Operations), Operations.operationDelete) - .map(function(o) { return o(selectedIDs, context); }) - .filter(function(o) { return o.available(); }); - - // deprecation warning - Radial Menu to be removed in iD v3 - var isRadialMenu = context.storage('edit-menu-style') === 'radial'; - if (isRadialMenu) { - operations = operations.slice(0,7); - operations.unshift(Operations.operationDelete(selectedIDs, context)); + if (noteFound) { + context.ui().sidebar.show(noteEditor.note('context.selectedNoteID')); // TODO: update to noteID reference } else { - operations.push(Operations.operationDelete(selectedIDs, context)); - } + var operations = _without(_values(Operations), Operations.operationDelete) + .map(function(o) { return o(selectedIDs, context); }) + .filter(function(o) { return o.available(); }); - operations.forEach(function(operation) { - if (operation.behavior) { - behaviors.push(operation.behavior); + // deprecation warning - Radial Menu to be removed in iD v3 + var isRadialMenu = context.storage('edit-menu-style') === 'radial'; + if (isRadialMenu) { + operations = operations.slice(0,7); + operations.unshift(Operations.operationDelete(selectedIDs, context)); + } else { + operations.push(Operations.operationDelete(selectedIDs, context)); } - }); - behaviors.forEach(function(behavior) { - context.install(behavior); - }); - - keybinding - .on(['[', 'pgup'], previousVertex) - .on([']', 'pgdown'], nextVertex) - .on(['{', uiCmd('⌘['), 'home'], firstVertex) - .on(['}', uiCmd('⌘]'), 'end'], lastVertex) - .on(['\\', 'pause'], nextParent) - .on('⎋', esc, true) - .on('space', toggleMenu); - - d3_select(document) - .call(keybinding); - - - // deprecation warning - Radial Menu to be removed in iD v3 - editMenu = isRadialMenu - ? uiRadialMenu(context, operations) - : uiEditMenu(context, operations); - - context.ui().sidebar - .select(singular() ? singular().id : null, newFeature); - - context.history() - .on('undone.select', update) - .on('redone.select', update); - - context.map() - .on('move.select', closeMenu) - .on('drawn.select', selectElements); - - context.surface() - .on('dblclick.select', dblclick); - - - selectElements(); - - if (selectedIDs.length > 1) { - var entities = uiSelectionList(context, selectedIDs); - context.ui().sidebar.show(entities); - } - - if (follow) { - var extent = geoExtent(); - var graph = context.graph(); - selectedIDs.forEach(function(id) { - var entity = context.entity(id); - extent._extend(entity.extent(graph)); + operations.forEach(function(operation) { + if (operation.behavior) { + behaviors.push(operation.behavior); + } }); - var loc = extent.center(); - context.map().centerEase(loc); - } else if (singular() && singular().type === 'way') { - context.map().pan([0,0]); // full redraw, to adjust z-sorting #2914 - } + behaviors.forEach(function(behavior) { + context.install(behavior); + }); - timeout = window.setTimeout(function() { - positionMenu(); - if (!suppressMenu) { - showMenu(); + keybinding + .on(['[', 'pgup'], previousVertex) + .on([']', 'pgdown'], nextVertex) + .on(['{', uiCmd('⌘['), 'home'], firstVertex) + .on(['}', uiCmd('⌘]'), 'end'], lastVertex) + .on(['\\', 'pause'], nextParent) + .on('⎋', esc, true) + .on('space', toggleMenu); + + d3_select(document) + .call(keybinding); + + + // deprecation warning - Radial Menu to be removed in iD v3 + editMenu = isRadialMenu + ? uiRadialMenu(context, operations) + : uiEditMenu(context, operations); + + context.ui().sidebar + .select(singular() ? singular().id : null, newFeature); + + context.history() + .on('undone.select', update) + .on('redone.select', update); + + context.map() + .on('move.select', closeMenu) + .on('drawn.select', selectElements); + + context.surface() + .on('dblclick.select', dblclick); + + + selectElements(); + + if (selectedIDs.length > 1) { + var entities = uiSelectionList(context, selectedIDs); + context.ui().sidebar.show(entities); } - }, 270); /* after any centerEase completes */ + if (follow) { + var extent = geoExtent(); + var graph = context.graph(); + selectedIDs.forEach(function(id) { + var entity = context.entity(id); + extent._extend(entity.extent(graph)); + }); + + var loc = extent.center(); + context.map().centerEase(loc); + } else if (singular() && singular().type === 'way') { + context.map().pan([0,0]); // full redraw, to adjust z-sorting #2914 + } + + timeout = window.setTimeout(function() { + positionMenu(); + if (!suppressMenu) { + showMenu(); + } + }, 270); /* after any centerEase completes */ + } }; diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 852a4232e..097efbd9c 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -130,18 +130,7 @@ export function uiNoteEditor(context) { // t[field.key] = utilGetSetValue(input) || undefined; dispatch.call('change', this, t, onInput); }; - } - - // // Input - // var inputSection = selection.selectAll('.note-input') - // .data([0]); - - // // enter - // var inputEnter = inputSection.enter() - // .append('div') - // .attr('class', 'tempClassName'); - - // update + } } function buttons(selection) { From b14d1b5061dd47a2b08a7246d731028f0c0bc0b8 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 3 Jul 2018 16:40:07 -0400 Subject: [PATCH 42/77] resolve conflicts, fix a few minor bugs prob caused by merging: - don't insert multiple times into the rtree in `services/osm.js` - use `d.id` instead of `d.key` as the key in `svg/notes.js` --- modules/behavior/select.js | 30 +++---- modules/core/context.js | 8 ++ modules/modes/select.js | 170 +++++++++++++++++++------------------ modules/services/osm.js | 7 +- modules/svg/notes.js | 45 +++++----- 5 files changed, 131 insertions(+), 129 deletions(-) diff --git a/modules/behavior/select.js b/modules/behavior/select.js index a0203c5ee..4ec87f9ca 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -118,31 +118,16 @@ export function behaviorSelect(context) { var datum = d3_event.target.__data__ || (lastMouse && lastMouse.target.__data__); var mode = context.mode(); - var entity; - - // check if datum is a note - if (datum instanceof osmNote) { - if (!isMultiselect) entity = datum; - else { entity = 'multiselectedNote'; } // if multiselected, ignore notes TODO: possibly give warning - } - - else { entity = datum && datum.properties && datum.properties.entity; } - + var entity = datum && datum.properties && datum.properties.entity; if (entity) datum = entity; if (datum && datum.type === 'midpoint') { datum = datum.parents[0]; } - if (!(datum instanceof osmEntity) && !(datum instanceof osmNote)) { - // 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)) { @@ -170,6 +155,15 @@ export function behaviorSelect(context) { context.enter(modeSelect(context, selectedIDs).suppressMenu(suppressMenu)); } } + + } else if (datum instanceof osmNote && !isMultiselect) { // clicked a Note.. + context.selectedNoteID(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 7e3ea68eb..4d758066e 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/select.js b/modules/modes/select.js index eeb7cfb66..01ca1dd99 100644 --- a/modules/modes/select.js +++ b/modules/modes/select.js @@ -443,91 +443,93 @@ export function modeSelect(context, selectedIDs) { if (noteFound) { context.ui().sidebar.show(noteEditor.note('context.selectedNoteID')); // TODO: update to noteID reference - } else { - var operations = _without(_values(Operations), Operations.operationDelete) - .map(function(o) { return o(selectedIDs, context); }) - .filter(function(o) { return o.available(); }); - - // deprecation warning - Radial Menu to be removed in iD v3 - var isRadialMenu = context.storage('edit-menu-style') === 'radial'; - if (isRadialMenu) { - operations = operations.slice(0,7); - operations.unshift(Operations.operationDelete(selectedIDs, context)); - } else { - operations.push(Operations.operationDelete(selectedIDs, context)); - } - - operations.forEach(function(operation) { - if (operation.behavior) { - behaviors.push(operation.behavior); - } - }); - - behaviors.forEach(function(behavior) { - context.install(behavior); - }); - - keybinding - .on(['[', 'pgup'], previousVertex) - .on([']', 'pgdown'], nextVertex) - .on(['{', uiCmd('⌘['), 'home'], firstVertex) - .on(['}', uiCmd('⌘]'), 'end'], lastVertex) - .on(['\\', 'pause'], nextParent) - .on('⎋', esc, true) - .on('space', toggleMenu); - - d3_select(document) - .call(keybinding); - - - // deprecation warning - Radial Menu to be removed in iD v3 - editMenu = isRadialMenu - ? uiRadialMenu(context, operations) - : uiEditMenu(context, operations); - - context.ui().sidebar - .select(singular() ? singular().id : null, newFeature); - - context.history() - .on('undone.select', update) - .on('redone.select', update); - - context.map() - .on('move.select', closeMenu) - .on('drawn.select', selectElements); - - context.surface() - .on('dblclick.select', dblclick); - - - selectElements(); - - if (selectedIDs.length > 1) { - var entities = uiSelectionList(context, selectedIDs); - context.ui().sidebar.show(entities); - } - - if (follow) { - var extent = geoExtent(); - var graph = context.graph(); - selectedIDs.forEach(function(id) { - var entity = context.entity(id); - extent._extend(entity.extent(graph)); - }); - - var loc = extent.center(); - context.map().centerEase(loc); - } else if (singular() && singular().type === 'way') { - context.map().pan([0,0]); // full redraw, to adjust z-sorting #2914 - } - - timeout = window.setTimeout(function() { - positionMenu(); - if (!suppressMenu) { - showMenu(); - } - }, 270); /* after any centerEase completes */ + return; } + + + var operations = _without(_values(Operations), Operations.operationDelete) + .map(function(o) { return o(selectedIDs, context); }) + .filter(function(o) { return o.available(); }); + + // deprecation warning - Radial Menu to be removed in iD v3 + var isRadialMenu = context.storage('edit-menu-style') === 'radial'; + if (isRadialMenu) { + operations = operations.slice(0,7); + operations.unshift(Operations.operationDelete(selectedIDs, context)); + } else { + operations.push(Operations.operationDelete(selectedIDs, context)); + } + + operations.forEach(function(operation) { + if (operation.behavior) { + behaviors.push(operation.behavior); + } + }); + + behaviors.forEach(function(behavior) { + context.install(behavior); + }); + + keybinding + .on(['[', 'pgup'], previousVertex) + .on([']', 'pgdown'], nextVertex) + .on(['{', uiCmd('⌘['), 'home'], firstVertex) + .on(['}', uiCmd('⌘]'), 'end'], lastVertex) + .on(['\\', 'pause'], nextParent) + .on('⎋', esc, true) + .on('space', toggleMenu); + + d3_select(document) + .call(keybinding); + + + // deprecation warning - Radial Menu to be removed in iD v3 + editMenu = isRadialMenu + ? uiRadialMenu(context, operations) + : uiEditMenu(context, operations); + + context.ui().sidebar + .select(singular() ? singular().id : null, newFeature); + + context.history() + .on('undone.select', update) + .on('redone.select', update); + + context.map() + .on('move.select', closeMenu) + .on('drawn.select', selectElements); + + context.surface() + .on('dblclick.select', dblclick); + + + selectElements(); + + if (selectedIDs.length > 1) { + var entities = uiSelectionList(context, selectedIDs); + context.ui().sidebar.show(entities); + } + + if (follow) { + var extent = geoExtent(); + var graph = context.graph(); + selectedIDs.forEach(function(id) { + var entity = context.entity(id); + extent._extend(entity.extent(graph)); + }); + + var loc = extent.center(); + context.map().centerEase(loc); + } else if (singular() && singular().type === 'way') { + context.map().pan([0,0]); // full redraw, to adjust z-sorting #2914 + } + + timeout = window.setTimeout(function() { + positionMenu(); + if (!suppressMenu) { + showMenu(); + } + }, 270); /* after any centerEase completes */ }; diff --git a/modules/services/osm.js b/modules/services/osm.js index 2d599faee..ae438562c 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -226,6 +226,7 @@ var parsers = { 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[id] = note; return note; } }; @@ -728,12 +729,8 @@ export default { } if (loadingNotes) { - var notes = parsed.map(function(d) { - cache.note[d.id] = d; - return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; - }); - cache.rtree.load(notes); dispatch.call('loadedNotes'); + } else { if (callback) { callback(err, _extend({ data: parsed }, tile)); diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 6cc9b47f9..7a2d174b4 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -9,12 +9,11 @@ import { uiNoteEditor } from '../ui'; export function svgNotes(projection, context, dispatch) { var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + // var noteEditor = uiNoteEditor(context); var minZoom = 12; var layer = d3_select(null); var _notes; - var _selected; - var noteEditor = uiNoteEditor(context); function init() { if (svgNotes.initialized) return; // run once @@ -66,40 +65,41 @@ export function svgNotes(projection, context, dispatch) { function click(which) { - _selected = which; + // _selected = which; context.map().centerEase(which.loc); - layer.selectAll('.note') - .classed('selected', function(d) { return d === _selected; }); + // layer.selectAll('.note') + // .classed('selected', function(d) { return d === _selected; }); // context.ui().sidebar.show(noteEditor.note(which)); } - function mouseover(which) { - layer.selectAll('.note') - .classed('hovered', function(d) { return d === which; }); + // function mouseover(which) { + // layer.selectAll('.note') + // .classed('hovered', function(d) { return d === which; }); - // context.ui().sidebar.show(noteEditor.note(which)); - } + // // context.ui().sidebar.show(noteEditor.note(which)); + // } - function mouseout() { - layer.selectAll('.note') - .classed('hovered', false); + // function mouseout() { + // layer.selectAll('.note') + // .classed('hovered', false); - // TODO: check if the item was clicked. If so, it should remain on the sidebar. - // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js - // context.ui().sidebar.hide(); - } + // // TODO: check if the item was clicked. If so, it should remain on the sidebar. + // // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js + // // context.ui().sidebar.hide(); + // } 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.key; }); + .data(data, function(d) { return d.id; }); // exit notes.exit() @@ -108,7 +108,8 @@ export function svgNotes(projection, context, dispatch) { // enter var notesEnter = notes.enter() .append('g') - .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }); + .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }) + .on('click', click); notesEnter .append('use') @@ -144,11 +145,11 @@ export function svgNotes(projection, context, dispatch) { notes .merge(notesEnter) .sort(function(a, b) { - return (a === _selected) ? 1 - : (b === _selected) ? -1 + return (a.id === selectedID) ? 1 + : (b.id === selectedID) ? -1 : b.loc[1] - a.loc[1]; // sort Y }) - .classed('selected', function(d) { return d === _selected; }) + .classed('selected', function(d) { return d.id === selectedID; }) .attr('transform', transform); } From bf499d943803d645a4d22f0eb29358d63ffd10a1 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 3 Jul 2018 18:11:59 -0400 Subject: [PATCH 43/77] Restore modeSelect, and make a new modeSelectNote just for the notes --- modules/behavior/select.js | 7 ++- modules/modes/index.js | 1 + modules/modes/select.js | 21 +------ modules/modes/select_note.js | 115 +++++++++++++++++++++++++++++++++++ modules/services/osm.js | 2 +- 5 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 modules/modes/select_note.js diff --git a/modules/behavior/select.js b/modules/behavior/select.js index 4ec87f9ca..9aed955e9 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -10,7 +10,8 @@ import { geoVecLength } from '../geo'; import { modeBrowse, - modeSelect + modeSelect, + modeSelectNote } from '../modes'; import { @@ -157,7 +158,9 @@ export function behaviorSelect(context) { } } else if (datum instanceof osmNote && !isMultiselect) { // clicked a Note.. - context.selectedNoteID(datum.id); + context + .selectedNoteID(datum.id) + .enter(modeSelectNote(context, datum.id)); } else { // clicked nothing.. context.selectedNoteID(null); diff --git a/modules/modes/index.js b/modules/modes/index.js index 4b2737be1..ffcdf9868 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 01ca1dd99..924314de9 100644 --- a/modules/modes/select.js +++ b/modules/modes/select.js @@ -34,12 +34,10 @@ import { osmWay } from '../osm'; -import { serviceOsm } from '../services'; - import { modeBrowse } from './browse'; import { modeDragNode } from './drag_node'; import * as Operations from '../operations/index'; -import { uiEditMenu, uiSelectionList, uiNoteEditor } from '../ui'; +import { uiEditMenu, uiSelectionList } from '../ui'; import { uiCmd } from '../ui/cmd'; import { utilEntityOrMemberSelector, utilEntitySelector } from '../util'; @@ -72,7 +70,6 @@ export function modeSelect(context, selectedIDs) { var newFeature = false; var suppressMenu = true; var follow = false; - var noteEditor = uiNoteEditor(context); var wrap = context.container() @@ -430,22 +427,8 @@ export function modeSelect(context, selectedIDs) { } } - var noteFound; - if (!checkSelectedIDs()) { - // check if any selectedIDs are within the loaded notes - var notes = serviceOsm.notes(context.projection); - var noteIDs = _map(notes, function(note) { return note.id; }); - noteFound = noteIDs.some(function(note) { - return selectedIDs.includes(note); - }); - if (!noteFound) return; - } - - if (noteFound) { - context.ui().sidebar.show(noteEditor.note('context.selectedNoteID')); // TODO: update to noteID reference - return; - } + if (!checkSelectedIDs()) return; var operations = _without(_values(Operations), Operations.operationDelete) .map(function(o) { return o(selectedIDs, context); }) diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js new file mode 100644 index 000000000..10d1e50ac --- /dev/null +++ b/modules/modes/select_note.js @@ -0,0 +1,115 @@ +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); + 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', false); + + context.map() + .on('drawn.select', null); + + context.ui().sidebar + .hide(); + }; + + + return mode; +} diff --git a/modules/services/osm.js b/modules/services/osm.js index ae438562c..961895686 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -226,7 +226,7 @@ var parsers = { 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[id] = note; + _noteCache.note[note.id] = note; return note; } }; From 73ee5c2fc9658e942ab3d17c55365efab1e7e237 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 3 Jul 2018 22:45:51 -0400 Subject: [PATCH 44/77] fixed select_note mode, cleaned note_editor, TODO: enable note save --- css/65_data.css | 2 ++ modules/behavior/select.js | 1 + modules/modes/select_note.js | 2 +- modules/renderer/map.js | 2 +- modules/svg/notes.js | 29 +++--------------------- modules/ui/note_editor.js | 44 +++++++++++++++++++----------------- 6 files changed, 31 insertions(+), 49 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index fa1a18ba3..078d8914a 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -71,4 +71,6 @@ #new-comment-input { width: 100%; height: 100px; + max-height: 300px; + min-height: 100px; } diff --git a/modules/behavior/select.js b/modules/behavior/select.js index 9aed955e9..9e0b3fee9 100644 --- a/modules/behavior/select.js +++ b/modules/behavior/select.js @@ -163,6 +163,7 @@ export function behaviorSelect(context) { .enter(modeSelectNote(context, datum.id)); } else { // clicked nothing.. + context.selectedNoteID(null); if (!isMultiselect && mode.id !== 'browse') { context.enter(modeBrowse(context)); diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js index 10d1e50ac..ed2a1ed94 100644 --- a/modules/modes/select_note.js +++ b/modules/modes/select_note.js @@ -101,7 +101,7 @@ export function modeSelectNote(context, selectedNoteID) { context.surface() .selectAll('.note.selected') - .classed('selected', false); + .classed('selected hovered', false); context.map() .on('drawn.select', null); diff --git a/modules/renderer/map.js b/modules/renderer/map.js index 10adf2c3d..1118488b7 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/svg/notes.js b/modules/svg/notes.js index 7a2d174b4..344e75e64 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -65,34 +65,11 @@ export function svgNotes(projection, context, dispatch) { function click(which) { - // _selected = which; - context.map().centerEase(which.loc); - - // layer.selectAll('.note') - // .classed('selected', function(d) { return d === _selected; }); - - // context.ui().sidebar.show(noteEditor.note(which)); + if (context.selectedNoteID() === which.id) { + context.map().centerEase(which.loc); + } } - - // function mouseover(which) { - // layer.selectAll('.note') - // .classed('hovered', function(d) { return d === which; }); - - // // context.ui().sidebar.show(noteEditor.note(which)); - // } - - - // function mouseout() { - // layer.selectAll('.note') - // .classed('hovered', false); - - // // TODO: check if the item was clicked. If so, it should remain on the sidebar. - // // TODO: handle multi-clicks. Otherwise, utilize behavior/select.js - // // context.ui().sidebar.hide(); - // } - - function update() { var service = getService(); var selectedID = context.selectedNoteID(); diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 097efbd9c..636999115 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -18,9 +18,10 @@ var _newComment; export function uiNoteEditor(context) { - var dispatch = d3_dispatch('change', 'cancel', 'save'); + var dispatch = d3_dispatch('change', 'cancel', 'save', 'changeInput'); var formFields = uiFormFields(context); var _fieldsArr; + var _modified = false; var _note; function localeDateString(s) { @@ -95,6 +96,24 @@ export function uiNoteEditor(context) { } + function newComment(selection) { + if (!context.selectedNoteID()) return; + // New Comment + var saveSection = selection.selectAll('.save-section') + .data([0]); + + saveSection = saveSection.enter() + .append('div') + .attr('class','save-section cf') + .merge(saveSection); + + saveSection + .call(saveHeader) + .call(input) + .call(buttons); + } + + function saveHeader(selection) { var header = selection.selectAll('.notesSaveHeader') .data([0]); @@ -118,7 +137,7 @@ export function uiNoteEditor(context) { .attr('placeholder', t('note.inputPlaceholder')) .attr('maxlength', 1000) .call(utilNoAuto) - // .on('input', change(true)) + .on('input', change(true)) .on('blur', change()) .on('change', change()) .merge(input); @@ -126,9 +145,7 @@ export function uiNoteEditor(context) { function change(onInput) { return function() { - var t = {}; - // t[field.key] = utilGetSetValue(input) || undefined; - dispatch.call('change', this, t, onInput); + dispatch.call('changeInput', this, onInput); }; } } @@ -201,22 +218,6 @@ export function uiNoteEditor(context) { }); } - function newComment(selection) { - // New Comment - var saveSection = selection.selectAll('.save-section') - .data([0]); - - saveSection = saveSection.enter() - .append('div') - .attr('class','save-section cf') - .merge(saveSection); - - saveSection - .call(saveHeader) - .call(input) - .call(buttons); - } - function render(selection) { var header = selection.selectAll('.header') @@ -245,6 +246,7 @@ export function uiNoteEditor(context) { .call(noteHeader) .call(noteComments) .call(newComment); + } From 6f1dc12f992414db3a01fc200218d88e96898bef Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Wed, 4 Jul 2018 03:20:16 -0400 Subject: [PATCH 45/77] Add `user` method to osm service, `_userCache`, user parser --- modules/services/osm.js | 97 +++++++++++++++++++++++++++++---------- modules/ui/note_editor.js | 2 +- 2 files changed, 73 insertions(+), 26 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 961895686..92f1d3cf7 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -42,6 +42,7 @@ var oauth = osmAuth({ var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; var _tileCache = { loaded: {}, inflight: {}, seen: {} }; var _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; +var _userCache = {}; var _changeset = {}; var _connectionID = 1; @@ -228,6 +229,29 @@ var parsers = { _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[uid] = user; + return user; } }; @@ -252,7 +276,9 @@ function parseXML(xml, callback, options) { if (!parser) return null; var uid; - if (child.nodeName === 'note') { + if (child.nodeName === 'user') { + uid = child.attributes.id.value; + } else if (child.nodeName === 'note') { uid = child.getElementsByTagName('id')[0].textContent; } else { uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); @@ -286,6 +312,7 @@ export default { _tileCache = { loaded: {}, inflight: {}, seen: {} }; _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; + _userCache = {}; _changeset = {}; return this; @@ -504,16 +531,16 @@ export default { }, - userDetails: function(callback) { - if (_userDetails) { - callback(undefined, _userDetails); + user: function(uid, callback) { + if (_userCache[uid] || !this.authenticated()) { // require auth + callback(undefined, _userCache[uid]); return; } var that = this; var cid = _connectionID; - function done(err, user_details) { + function done(err, xml) { if (err) { // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. if (err.status === 400 || err.status === 401 || err.status === 403) { @@ -525,30 +552,50 @@ export default { return callback({ message: 'Connection Switched', status: -1 }); } + var options = { skipSeen: false }; + return parseXML(xml, function(err, results) { + if (err) { + return callback(err); + } else { + return callback(undefined, results[0]); + } + }, options); + } - var u = user_details.getElementsByTagName('user')[0]; - var img = u.getElementsByTagName('img'); - var image_url = ''; + oauth.xhr({ method: 'GET', path: '/api/0.6/user/' + uid }, done); + }, - if (img && img[0] && img[0].getAttribute('href')) { - image_url = img[0].getAttribute('href'); - } - - var changesets = u.getElementsByTagName('changesets'); - var changesets_count = 0; - - if (changesets && changesets[0] && changesets[0].getAttribute('count')) { - changesets_count = changesets[0].getAttribute('count'); - } - - _userDetails = { - id: u.attributes.id.value, - display_name: u.attributes.display_name.value, - image_url: image_url, - changesets_count: changesets_count - }; + userDetails: function(callback) { + if (_userDetails) { callback(undefined, _userDetails); + return; + } + + var that = this; + var cid = _connectionID; + + function done(err, xml) { + 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 }); + } + + var options = { skipSeen: false }; + return parseXML(xml, function(err, results) { + if (err) { + return callback(err); + } else { + _userDetails = results[0]; + return callback(undefined, _userDetails); + } + }, options); } oauth.xhr({ method: 'GET', path: '/api/0.6/user/details' }, done); diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 636999115..c0ad6fe3f 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -66,7 +66,7 @@ export function uiNoteEditor(context) { var avatar = commentEnter .append('div') - .attr('class', 'comment-avatar'); + .attr('class', function(d) { return 'comment-avatar user-' + d.uid; }); avatar .call(svgIcon('#iD-icon-avatar', 'comment-avatar-icon')); From 263ec9e36ab11333c06e820bda7339ed77b9064c Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 5 Jul 2018 18:42:42 -0400 Subject: [PATCH 46/77] Add code to swap in avatar images for users that have them --- css/65_data.css | 1 + modules/ui/note_editor.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/css/65_data.css b/css/65_data.css index 078d8914a..1077607c7 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -48,6 +48,7 @@ .comment-avatar .icon.comment-avatar-icon { width: 40px; height: 40px; + object-fit: cover; border: 1px solid #ccc; border-radius: 20px; } diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index c0ad6fe3f..8e0124ab7 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -5,6 +5,7 @@ import { select as d3_select } from 'd3-selection'; import { t } from '../util/locale'; import { svgIcon } from '../svg'; +import { services } from '../services'; import { utilGetSetValue, utilNoAuto, @@ -93,6 +94,33 @@ export function uiNoteEditor(context) { .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.user(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); + }); + }); } From 01b33e3fb7bb14d8bf8fa1024d76fd109b1033d5 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 6 Jul 2018 11:28:01 -0400 Subject: [PATCH 47/77] Linkify the usernames --- modules/ui/note_editor.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 8e0124ab7..27ba1e7c3 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -65,11 +65,9 @@ export function uiNoteEditor(context) { .append('div') .attr('class', 'comment'); - var avatar = commentEnter + commentEnter .append('div') - .attr('class', function(d) { return 'comment-avatar user-' + d.uid; }); - - avatar + .attr('class', function(d) { return 'comment-avatar user-' + d.uid; }) .call(svgIcon('#iD-icon-avatar', 'comment-avatar-icon')); var main = commentEnter @@ -83,7 +81,20 @@ export function uiNoteEditor(context) { meta .append('div') .attr('class', 'comment-author') - .text(function(d) { return d.user || t('note.anonymous'); }); + .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'); }); + }); meta .append('div') From d137aa004687b00dfc59022dd144a83525a588ee Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 7 Jul 2018 00:25:42 -0400 Subject: [PATCH 48/77] Add "More/Less" toggle for too long comments (curr limit 600 chars) --- data/core.yaml | 2 ++ dist/locales/en.json | 6 +++-- modules/ui/note_editor.js | 51 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/data/core.yaml b/data/core.yaml index 2aa605657..b24851df8 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -614,6 +614,8 @@ en: note: Note title: Edit note anonymous: anonymous + more: More + less: Less commentTitle: Comments close: Resolve reopen: Reopen diff --git a/dist/locales/en.json b/dist/locales/en.json index b87f22d0a..d9296e4a1 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -747,6 +747,8 @@ "note": "Note", "title": "Edit note", "anonymous": "anonymous", + "more": "More", + "less": "Less", "commentTitle": "Comments", "close": "Resolve", "reopen": "Reopen", @@ -6686,7 +6688,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "Premium DigitalGlobe satellite imagery.", + "description": "DigitalGlobe-Premium is a mosaic composed of DigitalGlobe basemap with select regions filled with +Vivid or custom area of interest imagery, 50cm resolution or better, and refreshed more frequently with ongoing updates.", "name": "DigitalGlobe Premium Imagery" }, "DigitalGlobe-Premium-vintage": { @@ -6700,7 +6702,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "Standard DigitalGlobe satellite imagery.", + "description": "DigitalGlobe-Standard is a curated set of imagery covering 86% of the earth’s landmass, with 30-60cm resolution where available, backfilled by Landsat. Average age is 2.31 years, with some areas updated 2x per year.", "name": "DigitalGlobe Standard Imagery" }, "DigitalGlobe-Standard-vintage": { diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 27ba1e7c3..4a2cbb80a 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -1,7 +1,10 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { uiFormFields } from './form_fields'; -import { select as d3_select } from 'd3-selection'; +import { + event as d3_event, + select as d3_select +} from 'd3-selection'; import { t } from '../util/locale'; import { svgIcon } from '../svg'; @@ -21,6 +24,7 @@ var _newComment; export function uiNoteEditor(context) { var dispatch = d3_dispatch('change', 'cancel', 'save', 'changeInput'); var formFields = uiFormFields(context); + var commentLimit = 600; // add a "more" link to comments longer than this length var _fieldsArr; var _modified = false; var _note; @@ -103,14 +107,55 @@ export function uiNoteEditor(context) { main .append('div') - .attr('class', 'comment-text') - .text(function(d) { return d.text; }); + .attr('class', function(d) { + var trunc = (d.text.length > commentLimit); + return 'comment-text' + (trunc ? ' truncated' : ''); + }) + .text(function(d) { + var trunc = (d.text.length > commentLimit); + return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; + }); + + main + .each(function(d) { + var selection = d3_select(this); + var trunc = (d.text.length > commentLimit); + if (!trunc) return; + + selection + .append('a') + .attr('class', 'comment-toggle-more') + .attr('href', '#') + .attr('tabindex', -1) + .attr('target', '_blank') + .text(t('note.more')) + .on('click', toggleMore); + }); comments .call(replaceAvatars); } + function toggleMore() { + d3_event.preventDefault(); + + var selection = d3_select(this.parentNode); // select .comment-main + var commentText = selection.selectAll('.comment-text'); + var commentToggle = selection.selectAll('.comment-toggle-more'); + var trunc = !commentText.classed('truncated'); + + commentText + .classed('truncated', trunc) + .text(function(d) { + return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; + }); + + commentToggle + .text(t('note.' + (trunc ? 'more' : 'less'))); + } + + function replaceAvatars(selection) { var osm = services.osm; if (!osm) return; From 3eb3eefabdd95b7b015ff0ee3887ee3a2619d93c Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 7 Jul 2018 22:54:27 -0400 Subject: [PATCH 49/77] Combine Osm and Notes layers into the same ul --- modules/ui/map_data.js | 112 +++++++++++++---------------------------- 1 file changed, 35 insertions(+), 77 deletions(-) diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 1c46dcf96..47f215d55 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,105 +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] : []); + .data([0]); - // Exit - ul.exit() + ul = ul.enter() + .append('ul') + .attr('class', 'layer-list layer-list-osm') + .merge(ul); + + var li = ul.selectAll('.list-item') + .data(osmLayers); + + li.exit() .remove(); - // Enter - var ulEnter = ul.enter() - .append('ul') - .attr('class', 'layer-list layer-list-osm'); - - var liEnter = ulEnter + 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); - } - - function drawNotesItem(selection) { - var notes = layers.layer('notes'), - showsNotes = notes.enabled(); - - var ul = selection - .selectAll('.layer-list-notes') - .data(notes ? [0] : []); - - // Exit - ul.exit() - .remove(); - - // Enter - var ulEnter = ul.enter() - .append('ul') - .attr('class', 'layer-list layer-list-notes'); - - var liEnter = ulEnter - .append('li') - .attr('class', 'list-item-notes'); - - var labelEnter = liEnter - .append('label') - .call(tooltip() - .title(t('map_data.layers.notes.tooltip')) - .placement('top') - ); - - labelEnter - .append('input') - .attr('type', 'checkbox') - .on('change', function () { toggleLayer('notes'); }); - - labelEnter - .append('span') - .text(t('map_data.layers.notes.title')); - - // Update - ul = ul - .merge(ulEnter); - - ul.selectAll('.list-item-notes') - .classed('active', showsNotes) - .selectAll('input') - .property('checked', showsNotes); + .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') @@ -414,8 +373,7 @@ export function uiMapData(context) { function update() { _dataLayerContainer - .call(drawOsmItem) - .call(drawNotesItem) + .call(drawOsmItems) .call(drawPhotoItems) .call(drawGpxItem); From 7b42743513f0c6f4e971fd036c2a2391ac287080 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Sat, 7 Jul 2018 23:00:28 -0400 Subject: [PATCH 50/77] pacify some eslint warnings --- modules/svg/notes.js | 10 ++++++---- modules/ui/note_editor.js | 5 ----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 344e75e64..7680dacc8 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -1,15 +1,13 @@ import _throttle from 'lodash-es/throttle'; + import { select as d3_select } from 'd3-selection'; + import { svgPointTransform } from './index'; import { services } from '../services'; -import { osmNote } from '../osm'; - -import { uiNoteEditor } from '../ui'; export function svgNotes(projection, context, dispatch) { var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); - // var noteEditor = uiNoteEditor(context); var minZoom = 12; var layer = d3_select(null); var _notes; @@ -31,6 +29,7 @@ export function svgNotes(projection, context, dispatch) { layer.style('display', 'none'); } + function getService() { if (services.osm && !_notes) { _notes = services.osm; @@ -42,6 +41,7 @@ export function svgNotes(projection, context, dispatch) { return _notes; } + function showLayer() { editOn(); @@ -53,6 +53,7 @@ export function svgNotes(projection, context, dispatch) { .on('end', function () { dispatch.call('change'); }); } + function hideLayer() { throttledRedraw.cancel(); @@ -70,6 +71,7 @@ export function svgNotes(projection, context, dispatch) { } } + function update() { var service = getService(); var selectedID = context.selectedNoteID(); diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 4a2cbb80a..cb117e769 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -10,12 +10,10 @@ import { t } from '../util/locale'; import { svgIcon } from '../svg'; import { services } from '../services'; import { - utilGetSetValue, utilNoAuto, utilRebind } from '../util'; -import { uiField } from './field'; import { utilDetect } from '../util/detect'; var _newComment; @@ -23,9 +21,7 @@ var _newComment; export function uiNoteEditor(context) { var dispatch = d3_dispatch('change', 'cancel', 'save', 'changeInput'); - var formFields = uiFormFields(context); var commentLimit = 600; // add a "more" link to comments longer than this length - var _fieldsArr; var _modified = false; var _note; @@ -337,7 +333,6 @@ export function uiNoteEditor(context) { noteEditor.note = function(_) { if (!arguments.length) return _note; _note = _; - _fieldsArr = null; return noteEditor; }; From 0f49514fbbec7c1e94c030d35ebe80dee0bb2d2d Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 8 Jul 2018 10:27:44 -0500 Subject: [PATCH 51/77] updated: removed centering when clicking a note --- dist/locales/en.json | 4 ++-- modules/svg/notes.js | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/dist/locales/en.json b/dist/locales/en.json index d9296e4a1..d25af4491 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -6688,7 +6688,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "DigitalGlobe-Premium is a mosaic composed of DigitalGlobe basemap with select regions filled with +Vivid or custom area of interest imagery, 50cm resolution or better, and refreshed more frequently with ongoing updates.", + "description": "Premium DigitalGlobe satellite imagery.", "name": "DigitalGlobe Premium Imagery" }, "DigitalGlobe-Premium-vintage": { @@ -6702,7 +6702,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "DigitalGlobe-Standard is a curated set of imagery covering 86% of the earth’s landmass, with 30-60cm resolution where available, backfilled by Landsat. Average age is 2.31 years, with some areas updated 2x per year.", + "description": "Standard DigitalGlobe satellite imagery.", "name": "DigitalGlobe Standard Imagery" }, "DigitalGlobe-Standard-vintage": { diff --git a/modules/svg/notes.js b/modules/svg/notes.js index 7680dacc8..a3b2f66d0 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -65,13 +65,6 @@ export function svgNotes(projection, context, dispatch) { } - function click(which) { - if (context.selectedNoteID() === which.id) { - context.map().centerEase(which.loc); - } - } - - function update() { var service = getService(); var selectedID = context.selectedNoteID(); @@ -87,8 +80,7 @@ export function svgNotes(projection, context, dispatch) { // enter var notesEnter = notes.enter() .append('g') - .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }) - .on('click', click); + .attr('class', function(d) { return 'note note-' + d.id + ' ' + d.status; }); notesEnter .append('use') From 14356cefe2169a554cb44066a6a86c8090c91b11 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Sun, 8 Jul 2018 12:50:02 -0500 Subject: [PATCH 52/77] WIP: toggle note status --- css/65_data.css | 8 +++- data/core.yaml | 3 +- modules/services/osm.js | 67 +++++++++++++++++++++++++- modules/ui/note_editor.js | 98 ++++++++++++++++++++++++++++++--------- 4 files changed, 151 insertions(+), 25 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 1077607c7..4f1c55cf6 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -53,9 +53,12 @@ border-radius: 20px; } .comment-metadata { - display: flex; + display: -webkit-flex; /* Safari */ + -webkit-flex-wrap: wrap; /* Safari 6.1+ */ flex-flow: row nowrap; + flex-wrap: wrap; justify-content: space-between; + } .comment-author { font-weight: bold; @@ -67,6 +70,9 @@ .comment-text { color: #333; margin-top: 10px; + overflow-y: hidden; /* TODO: give scroll bar or replace with accordion */ + max-height: 250px; + } #new-comment-input { diff --git a/data/core.yaml b/data/core.yaml index b24851df8..3e38f40d4 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -622,7 +622,8 @@ en: newComment: New Comment inputPlaceholder: Enter a comment to share with other users. comment: Comment - commentResolve: Comment & Resolve + commentClose: Comment & Resolve + commentReopen: Comment & Reopen save: Save comment cancel: Cancel help: diff --git a/modules/services/osm.js b/modules/services/osm.js index 92f1d3cf7..e2d0883cc 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -44,6 +44,7 @@ var _tileCache = { loaded: {}, inflight: {}, seen: {} }; var _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; var _userCache = {}; var _changeset = {}; +var _noteChangeset = {}; var _connectionID = 1; var _tileZoom = 16; @@ -309,11 +310,13 @@ export default { _forEach(_tileCache.inflight, abortRequest); _forEach(_noteCache.inflight, abortRequest); if (_changeset.inflight) abortRequest(_changeset.inflight); + if (_noteChangeset.inflight) abortRequest(_changeset.inflight); _tileCache = { loaded: {}, inflight: {}, seen: {} }; _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; _userCache = {}; _changeset = {}; + _noteChangeset = {}; return this; }, @@ -894,5 +897,67 @@ export default { notesCache: function() { return _noteCache; - } + }, + + + toggleNoteStatus: function(note, comment, callback) { + if (!(note instanceof osmNote) && !(this.getNote(note.id))) return; + if (!this.authenticated()) return; + + var that = this; + var cid = _connectionID; + + function done(err, xml) { + 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 }); + } + + return callback(xml); + } + + var status = note.status === 'open' ? 'close' : 'reopen'; + + var path = '/api/0.6/notes/' + note.id + '/' + status; + path += comment ? '?text=' + comment : ''; + + _noteChangeset.inflight = oauth.xhr({ method: 'POST', path: path }, done); + + }, + + addNoteComment: function(note, comment, callback) { + if (!(note instanceof osmNote) && !(this.getNote(note.id))) return; + if (!this.authenticated()) return; + if (!comment) return; + + var that = this; + var cid = _connectionID; + + function done(err, xml) { + 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 }); + } + + return callback(xml); + } + + var path = '/api/0.6/notes/' + note.id + '/comment?text=' + comment; + + _noteChangeset.inflight = oauth.xhr({ method: 'POST', path: path }, done); + + }, + }; diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index cb117e769..6038a421a 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -1,5 +1,4 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; -import { uiFormFields } from './form_fields'; import { event as d3_event, @@ -15,14 +14,14 @@ import { } from '../util'; import { utilDetect } from '../util/detect'; - -var _newComment; +import { modeBrowse } from '../modes'; export function uiNoteEditor(context) { var dispatch = d3_dispatch('change', 'cancel', 'save', 'changeInput'); var commentLimit = 600; // add a "more" link to comments longer than this length - var _modified = false; + var _inputValue; + var _newComment; var _note; function localeDateString(s) { @@ -39,6 +38,65 @@ export function uiNoteEditor(context) { render(selection); } + function cancel() { + _newComment = false; + context.selectedNoteID(null); + context.enter(modeBrowse(context)); + } + + function save(updateFunction) { + var osm = context.connection(); + if (!osm) { + context.enter(modeBrowse(context)); + return; + } + + // If user somehow got logged out mid-save, try to reauthenticate.. + // This can happen if they were logged in from before, but the tokens are no longer valid. + if (!osm.authenticated()) { + + // TODO: dispatch 'notAuthenticated' to give warning + + osm.authenticate(function(err) { + if (err) { // quit save mode.. + context.enter(modeBrowse(context)); + return; + } else { + save(updateFunction); // continue where we left off.. + } + }); + return; + } + + function parseResults(results) { + + // call success + + // otherwise, call failure + } + + function success(response) { + console.log('success!', response); + } + + function failure(response) { + console.log('failure!', response); + } + + updateFunction(parseResults); + } + + function toggleNoteStatus(parseResults) { + if (!_note || !_note.status || !context.selectedNoteID) return; + services.osm.toggleNoteStatus(_note, _inputValue, parseResults); + } + + function addNoteComment(parseResults) { + if (!_note || !_note.status || !context.selectedNoteID) return; + services.osm..addNoteComment(_note, _inputValue, parseResults); + } + + function noteHeader(selection) { selection.selectAll('.note-header') @@ -217,16 +275,18 @@ export function uiNoteEditor(context) { .attr('placeholder', t('note.inputPlaceholder')) .attr('maxlength', 1000) .call(utilNoAuto) - .on('input', change(true)) - .on('blur', change()) - .on('change', change()) + .on('input', modified) + .on('blur', modified) + .on('change', modified) .merge(input); - function change(onInput) { - return function() { - dispatch.call('changeInput', this, onInput); - }; + function modified(onInput) { + _modified = !!this.value; + _inputValue = this.value; + + // TODO: fix this event handling & update button text to reflect if there is input + // dispatch.call('inputModified', this, _inputValue); } } @@ -272,20 +332,13 @@ export function uiNoteEditor(context) { .merge(buttonEnter); buttonSection.selectAll('.close-button') - .on('click.close', function() { - console.log('close button clicked'); - }); + .on('click', function() { save(toggleNoteStatus) }); buttonSection.selectAll('.reopen-button') - .on('click.reopen', function() { - console.log('reopen button clicked'); - }); + .on('click', function() { save(toggleNoteStatus) }); buttonSection.selectAll('.cancel-button') - .on('click.cancel', function() { - // var selectedID = commitChanges.entityID(); TODO: cancel note event - // dispatch.call('cancel', this, selectedID); - }); + .on('click.cancel', cancel); buttonSection.selectAll('.save-button') .attr('disabled', function() { @@ -294,7 +347,8 @@ export function uiNoteEditor(context) { }) .on('click.save', function() { this.blur(); // avoid keeping focus on the button - #4641 - // dispatch.call('saveNote', this, _newComment); TODO: saveNote event + save(addNoteComment); + dispatch.call('saveNote', this, _newComment); // TODO: saveNote event }); } From c460c03da5d0e79d7fced4bf4f9088016ef7fd32 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 9 Jul 2018 10:11:50 -0400 Subject: [PATCH 53/77] fixed typo --- dist/locales/en.json | 3 ++- modules/ui/note_editor.js | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dist/locales/en.json b/dist/locales/en.json index d25af4491..1a99cfc49 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -755,7 +755,8 @@ "newComment": "New Comment", "inputPlaceholder": "Enter a comment to share with other users.", "comment": "Comment", - "commentResolve": "Comment & Resolve", + "commentClose": "Comment & Resolve", + "commentReopen": "Comment & Reopen", "save": "Save comment", "cancel": "Cancel" }, diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 6038a421a..7e9b4a6d5 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -23,6 +23,7 @@ export function uiNoteEditor(context) { var _inputValue; var _newComment; var _note; + var _modified; function localeDateString(s) { if (!s) return null; @@ -93,7 +94,7 @@ export function uiNoteEditor(context) { function addNoteComment(parseResults) { if (!_note || !_note.status || !context.selectedNoteID) return; - services.osm..addNoteComment(_note, _inputValue, parseResults); + services.osm.addNoteComment(_note, _inputValue, parseResults); } @@ -332,10 +333,10 @@ export function uiNoteEditor(context) { .merge(buttonEnter); buttonSection.selectAll('.close-button') - .on('click', function() { save(toggleNoteStatus) }); + .on('click', function() { save(toggleNoteStatus); }); buttonSection.selectAll('.reopen-button') - .on('click', function() { save(toggleNoteStatus) }); + .on('click', function() { save(toggleNoteStatus); }); buttonSection.selectAll('.cancel-button') .on('click.cancel', cancel); From eafae6c58cf1a54936d06c52b072bcae52fafee7 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Tue, 10 Jul 2018 13:50:36 -0400 Subject: [PATCH 54/77] WIP: update service calls; todo: note update event handling --- modules/modes/select_note.js | 13 ++++++- modules/svg/notes.js | 30 +++++++++------ modules/ui/note_editor.js | 74 ++++++++++++++++++++---------------- 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js index ed2a1ed94..9c8e97f5b 100644 --- a/modules/modes/select_note.js +++ b/modules/modes/select_note.js @@ -11,6 +11,8 @@ import { behaviorSelect } from '../behavior'; +import { svgNotes } from '../svg'; + import { services } from '../services'; import { modeBrowse } from './browse'; import { uiNoteEditor } from '../ui'; @@ -24,7 +26,16 @@ export function modeSelectNote(context, selectedNoteID) { var osm = services.osm; var keybinding = d3_keybinding('select-note'); - var noteEditor = uiNoteEditor(context); + var noteEditor = uiNoteEditor(context) + .on('updateNote', function() { + + // .call(drawNotes); // TODO: update and redraw notes + + var note = checkSelectedID(); + if (!note) return; + context.ui().sidebar + .show(noteEditor.note(note)); + }); var behaviors = [ behaviorHover(context), behaviorSelect(context), diff --git a/modules/svg/notes.js b/modules/svg/notes.js index a3b2f66d0..d34a0dcdd 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -125,14 +125,28 @@ export function svgNotes(projection, context, dispatch) { } - function drawNotes(selection) { - var enabled = svgNotes.enabled; - var service = getService(); + function toggleEdit(service, enabled) { function dimensions() { return [window.innerWidth, window.innerHeight]; } + if (enabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadNotes(projection, dimensions()); + update(); + } else { + editOff(); + } + } + } + + + function drawNotes(selection) { + var enabled = svgNotes.enabled; + var service = getService(); + layer = selection.selectAll('.layer-notes') .data(service ? [0] : []); @@ -145,15 +159,7 @@ export function svgNotes(projection, context, dispatch) { .style('display', enabled ? 'block' : 'none') .merge(layer); - if (enabled) { - if (service && ~~context.map().zoom() >= minZoom) { - editOn(); - update(); - service.loadNotes(projection, dimensions()); - } else { - editOff(); - } - } + toggleEdit(service, enabled); } drawNotes.enabled = function(_) { diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 7e9b4a6d5..9ef2333bc 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -18,7 +18,8 @@ import { modeBrowse } from '../modes'; export function uiNoteEditor(context) { - var dispatch = d3_dispatch('change', 'cancel', 'save', 'changeInput'); + // TODO: use 'toggleNote' and 'saveNote' to add 'thank you' warning to the sidebar + var dispatch = d3_dispatch('change', 'cancel', 'save', 'modifiedInput', 'updateNote', 'toggleNote', 'saveNote'); var commentLimit = 600; // add a "more" link to comments longer than this length var _inputValue; var _newComment; @@ -69,19 +70,26 @@ export function uiNoteEditor(context) { return; } - function parseResults(results) { + function parseResults(results) { // TODO: simplify result parsing + dispatch.call('change', results); // call success - + if (results) { + success(results); + } // otherwise, call failure + else { + failure(results); + } } - function success(response) { - console.log('success!', response); + function success(results) { + console.log('success!', results); // TODO: handle success + dispatch.apply('updateNote'); } - function failure(response) { - console.log('failure!', response); + function failure(results) { // TODO: handle failure & errors + console.log('failure!', results); } updateFunction(parseResults); @@ -256,7 +264,8 @@ export function uiNoteEditor(context) { function saveHeader(selection) { var header = selection.selectAll('.notesSaveHeader') .data([0]); - header = header.enter() + + header.enter() .append('h4') .attr('class', '.notesSaveHeader') .text(t('note.newComment')) @@ -265,29 +274,25 @@ export function uiNoteEditor(context) { function input(selection) { - // Input - var input = selection.selectAll('textarea') - .data([0]); - // enter - input = input.enter() - .append('textarea') - .attr('id', 'new-comment-input') - .attr('placeholder', t('note.inputPlaceholder')) - .attr('maxlength', 1000) - .call(utilNoAuto) - .on('input', modified) - .on('blur', modified) - .on('change', modified) - .merge(input); + var input = selection.selectAll('textarea') + .data([0]); + + input.enter() + .append('textarea') + .attr('id', 'new-comment-input') + .attr('placeholder', t('note.inputPlaceholder')) + .attr('maxlength', 1000) + .call(utilNoAuto) + .on('input', change) + .on('blur', change) + .merge(input); - function modified(onInput) { - _modified = !!this.value; + function change() { _inputValue = this.value; - - // TODO: fix this event handling & update button text to reflect if there is input - // dispatch.call('inputModified', this, _inputValue); + console.log(_inputValue); + dispatch.apply('modifiedInput'); } } @@ -332,11 +337,11 @@ export function uiNoteEditor(context) { buttonSection = buttonSection .merge(buttonEnter); - buttonSection.selectAll('.close-button') - .on('click', function() { save(toggleNoteStatus); }); - - buttonSection.selectAll('.reopen-button') - .on('click', function() { save(toggleNoteStatus); }); + buttonSection.selectAll('.closed-button,.open-button') + .on('click', function() { + save(toggleNoteStatus); + dispatch.apply('toggleNote', this); // TODO: dispatch toggleNote event + }); buttonSection.selectAll('.cancel-button') .on('click.cancel', cancel); @@ -346,10 +351,13 @@ export function uiNoteEditor(context) { var n = d3_select('#new-comment-input').node(); return (n && n.value.length) ? null : true; }) + .on('modifiedInput', function() { + // TODO: determine how to toggle button on input via triggering 'modifiedInput' event + }) .on('click.save', function() { this.blur(); // avoid keeping focus on the button - #4641 save(addNoteComment); - dispatch.call('saveNote', this, _newComment); // TODO: saveNote event + dispatch.apply('saveNote', this, _newComment); // TODO: dispatch saveNote event }); } From 0d7c292c23c6205240e73365e0eb8b6e7c42f6c6 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 10 Jul 2018 21:45:02 -0400 Subject: [PATCH 55/77] Refactor out uiNoteHeader and uiNoteComments into separate modules uiNoteEditor was getting big! --- modules/modes/select_note.js | 5 +- modules/ui/index.js | 2 + modules/ui/note_comments.js | 161 ++++++++++++++++++++++++++++++ modules/ui/note_editor.js | 184 +++++------------------------------ modules/ui/note_header.js | 27 +++++ 5 files changed, 213 insertions(+), 166 deletions(-) create mode 100644 modules/ui/note_comments.js create mode 100644 modules/ui/note_header.js diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js index 9c8e97f5b..b9cf872d9 100644 --- a/modules/modes/select_note.js +++ b/modules/modes/select_note.js @@ -11,8 +11,6 @@ import { behaviorSelect } from '../behavior'; -import { svgNotes } from '../svg'; - import { services } from '../services'; import { modeBrowse } from './browse'; import { uiNoteEditor } from '../ui'; @@ -28,14 +26,13 @@ export function modeSelectNote(context, selectedNoteID) { var keybinding = d3_keybinding('select-note'); var noteEditor = uiNoteEditor(context) .on('updateNote', function() { - // .call(drawNotes); // TODO: update and redraw notes - var note = checkSelectedID(); if (!note) return; context.ui().sidebar .show(noteEditor.note(note)); }); + var behaviors = [ behaviorHover(context), behaviorSelect(context), diff --git a/modules/ui/index.js b/modules/ui/index.js index 112b90d00..c8caebac5 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -34,7 +34,9 @@ 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 { uiPresetEditor } from './preset_editor'; export { uiPresetIcon } from './preset_icon'; export { uiPresetList } from './preset_list'; diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js new file mode 100644 index 000000000..ebb88214a --- /dev/null +++ b/modules/ui/note_comments.js @@ -0,0 +1,161 @@ +import { + event as d3_event, + 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 commentLimit = 600; // add a "more" link to comments longer than this length + var _note; + + + function noteComments(selection) { + var comments = selection.selectAll('.comments') + .data([0]); + + comments = comments.enter() + .append('div') + .attr('class', 'comments') + .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', function(d) { + var trunc = (d.text.length > commentLimit); + return 'comment-text' + (trunc ? ' truncated' : ''); + }) + .text(function(d) { + var trunc = (d.text.length > commentLimit); + return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; + }); + + mainEnter + .each(function(d) { + var selection = d3_select(this); + var trunc = (d.text.length > commentLimit); + if (!trunc) return; + + selection + .append('a') + .attr('class', 'comment-toggle-more') + .attr('href', '#') + .attr('tabindex', -1) + .attr('target', '_blank') + .text(t('note.more')) + .on('click', toggleMore); + }); + + comments + .call(replaceAvatars); + } + + + function toggleMore() { + d3_event.preventDefault(); + + var selection = d3_select(this.parentNode); // select .comment-main + var commentText = selection.selectAll('.comment-text'); + var commentToggle = selection.selectAll('.comment-toggle-more'); + var trunc = !commentText.classed('truncated'); + + commentText + .classed('truncated', trunc) + .text(function(d) { + return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; + }); + + commentToggle + .text(t('note.' + (trunc ? 'more' : 'less'))); + } + + + 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.user(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 index 9ef2333bc..7e68df82c 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -8,6 +8,10 @@ import { import { t } from '../util/locale'; import { svgIcon } from '../svg'; import { services } from '../services'; + +import { uiNoteComments } from './note_comments'; +import { uiNoteHeader } from './note_header'; + import { utilNoAuto, utilRebind @@ -20,21 +24,13 @@ import { modeBrowse } from '../modes'; export function uiNoteEditor(context) { // TODO: use 'toggleNote' and 'saveNote' to add 'thank you' warning to the sidebar var dispatch = d3_dispatch('change', 'cancel', 'save', 'modifiedInput', 'updateNote', 'toggleNote', 'saveNote'); - var commentLimit = 600; // add a "more" link to comments longer than this length + var noteHeader = uiNoteHeader(); + var noteComments = uiNoteComments(); var _inputValue; var _newComment; var _note; var _modified; - 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); - } - function noteEditor(selection) { render(selection); @@ -95,154 +91,19 @@ export function uiNoteEditor(context) { updateFunction(parseResults); } + function toggleNoteStatus(parseResults) { if (!_note || !_note.status || !context.selectedNoteID) return; services.osm.toggleNoteStatus(_note, _inputValue, parseResults); } + function addNoteComment(parseResults) { if (!_note || !_note.status || !context.selectedNoteID) return; services.osm.addNoteComment(_note, _inputValue, parseResults); } - - function noteHeader(selection) { - selection.selectAll('.note-header') - .data([_note], function(d) { return d.id; }) - .enter() - .append('h3') - .attr('class', 'note-header') - .text(function(d) { return String(t('note.note') + ' ' + d.id); }); - } - - - function noteComments(selection) { - var comments = selection.selectAll('.comments') - .data([0]); - - comments = comments.enter() - .append('div') - .attr('class', 'comments') - .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 main = commentEnter - .append('div') - .attr('class', 'comment-main'); - - var meta = main - .append('div') - .attr('class', 'comment-metadata'); - - meta - .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'); }); - }); - - meta - .append('div') - .attr('class', 'comment-date') - .text(function(d) { return d.action + ' ' + localeDateString(d.date); }); - - main - .append('div') - .attr('class', function(d) { - var trunc = (d.text.length > commentLimit); - return 'comment-text' + (trunc ? ' truncated' : ''); - }) - .text(function(d) { - var trunc = (d.text.length > commentLimit); - return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; - }); - - main - .each(function(d) { - var selection = d3_select(this); - var trunc = (d.text.length > commentLimit); - if (!trunc) return; - - selection - .append('a') - .attr('class', 'comment-toggle-more') - .attr('href', '#') - .attr('tabindex', -1) - .attr('target', '_blank') - .text(t('note.more')) - .on('click', toggleMore); - }); - - comments - .call(replaceAvatars); - } - - - function toggleMore() { - d3_event.preventDefault(); - - var selection = d3_select(this.parentNode); // select .comment-main - var commentText = selection.selectAll('.comment-text'); - var commentToggle = selection.selectAll('.comment-toggle-more'); - var trunc = !commentText.classed('truncated'); - - commentText - .classed('truncated', trunc) - .text(function(d) { - return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; - }); - - commentToggle - .text(t('note.' + (trunc ? 'more' : 'less'))); - } - - - 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.user(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 newComment(selection) { if (!context.selectedNoteID()) return; // New Comment @@ -274,19 +135,18 @@ export function uiNoteEditor(context) { function input(selection) { + var input = selection.selectAll('textarea') + .data([0]); - var input = selection.selectAll('textarea') - .data([0]); - - input.enter() - .append('textarea') - .attr('id', 'new-comment-input') - .attr('placeholder', t('note.inputPlaceholder')) - .attr('maxlength', 1000) - .call(utilNoAuto) - .on('input', change) - .on('blur', change) - .merge(input); + input.enter() + .append('textarea') + .attr('id', 'new-comment-input') + .attr('placeholder', t('note.inputPlaceholder')) + .attr('maxlength', 1000) + .call(utilNoAuto) + .on('input', change) + .on('blur', change) + .merge(input); function change() { @@ -296,6 +156,7 @@ export function uiNoteEditor(context) { } } + function buttons(selection) { // Buttons var buttonSection = selection.selectAll('.buttons') @@ -386,10 +247,9 @@ export function uiNoteEditor(context) { .enter() .append('div') .attr('class', 'modal-section note-editor') - .call(noteHeader) - .call(noteComments) + .call(noteHeader.note(_note)) + .call(noteComments.note(_note)) .call(newComment); - } diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js new file mode 100644 index 000000000..ae0d0d875 --- /dev/null +++ b/modules/ui/note_header.js @@ -0,0 +1,27 @@ +import { t } from '../util/locale'; +import { svgIcon } from '../svg'; + + +export function uiNoteHeader() { + var _note; + + + function noteHeader(selection) { + selection.selectAll('.note-header') + .data([_note], function(d) { return d.id; }) + .enter() + .append('h3') + .attr('class', 'note-header') + .text(function(d) { return String(t('note.note') + ' ' + d.id); }); + } + + + noteHeader.note = function(_) { + if (!arguments.length) return _note; + _note = _; + return noteHeader; + }; + + + return noteHeader; +} From 7423d192d4e0ab08092048c52efc525e57df6885 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 10 Jul 2018 23:02:37 -0400 Subject: [PATCH 56/77] Implement multi-fetch get for users from OSM API Eagerly load user details after loading notes (re: openstreetmap/openstreetmap-website#1921) --- modules/services/osm.js | 104 +++++++++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index e2d0883cc..922296916 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -6,6 +6,7 @@ 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'; @@ -42,7 +43,7 @@ var oauth = osmAuth({ var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; var _tileCache = { loaded: {}, inflight: {}, seen: {} }; var _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; -var _userCache = {}; +var _userCache = { toLoad: {}, user: {} }; var _changeset = {}; var _noteChangeset = {}; @@ -136,7 +137,15 @@ function parseComments(comments) { 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); } @@ -251,7 +260,8 @@ var parsers = { user.changesets_count = changesets[0].getAttribute('count'); } - _userCache[uid] = user; + _userCache.user[uid] = user; + delete _userCache.toLoad[uid]; return user; } }; @@ -279,8 +289,14 @@ function parseXML(xml, callback, options) { var uid; if (child.nodeName === 'user') { uid = child.attributes.id.value; + if (options.skipSeen && _userCache.user[uid]) { + delete _userCache.toLoad[uid]; + return null; + } + } else if (child.nodeName === 'note') { uid = child.getElementsByTagName('id')[0].textContent; + } else { uid = osmEntity.id.fromOSM(child.nodeName, child.attributes.id.value); if (options.skipSeen) { @@ -314,7 +330,7 @@ export default { _tileCache = { loaded: {}, inflight: {}, seen: {} }; _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; - _userCache = {}; + _userCache = { toLoad: {}, user: {} }; _changeset = {}; _noteChangeset = {}; @@ -434,6 +450,8 @@ export default { }, + // load multiple entities + // callback may be called multiple times loadMultiple: function(ids, callback) { var that = this; @@ -455,11 +473,6 @@ export default { }, - authenticated: function() { - return oauth.authenticated(); - }, - - putChangeset: function(changeset, changes, callback) { if (_changeset.inflight) { return callback({ message: 'Changeset already inflight', status: -2 }, changeset); @@ -534,9 +547,62 @@ export default { }, + // load multiple users + // callback may be called multiple times + 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; + + function done(err, xml) { + 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 }); + } + + var options = { skipSeen: true }; + return parseXML(xml, function(err, results) { + if (err) { + return callback(err); + } else { + return callback(undefined, results); + } + }, options); + } + + _chunk(toLoad, 150).forEach(function(arr) { + oauth.xhr({ method: 'GET', path: '/api/0.6/users?users=' + arr.join() }, done); + }); + }, + + user: function(uid, callback) { - if (_userCache[uid] || !this.authenticated()) { // require auth - callback(undefined, _userCache[uid]); + if (_userCache.user[uid] || !this.authenticated()) { // require auth + delete _userCache.toLoad[uid]; + callback(undefined, _userCache.user[uid]); return; } @@ -555,7 +621,7 @@ export default { return callback({ message: 'Connection Switched', status: -1 }); } - var options = { skipSeen: false }; + var options = { skipSeen: true }; return parseXML(xml, function(err, results) { if (err) { return callback(err); @@ -590,7 +656,7 @@ export default { return callback({ message: 'Connection Switched', status: -1 }); } - var options = { skipSeen: false }; + var options = { skipSeen: true }; return parseXML(xml, function(err, results) { if (err) { return callback(err); @@ -709,11 +775,17 @@ export default { var that = this; // check if loading entities, or notes - var path, cache, tilezoom; + var path, cache, tilezoom, throttleLoadUsers; if (loadingNotes) { path = '/api/0.6/notes?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; @@ -779,6 +851,7 @@ export default { } if (loadingNotes) { + throttleLoadUsers(); dispatch.call('loadedNotes'); } else { @@ -834,6 +907,11 @@ export default { }, + authenticated: function() { + return oauth.authenticated(); + }, + + authenticate: function(callback) { var that = this; var cid = _connectionID; From f52f24b5175db2219baf891307aaa817d7a6ae49 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Tue, 10 Jul 2018 23:28:37 -0400 Subject: [PATCH 57/77] Add noteOptions, default to 10k note limit and 7 days closed Also bump note tile zoom to z12. There just aren't that many notes, so this reduces API calls. --- modules/services/osm.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 922296916..7ef04526d 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -49,7 +49,7 @@ var _noteChangeset = {}; var _connectionID = 1; var _tileZoom = 16; -var _noteZoom = 13; +var _noteZoom = 12; var _rateLimitError; var _userChangesets; var _userDetails; @@ -567,7 +567,6 @@ export default { if (!this.authenticated()) return; // require auth } - var that = this; var cid = _connectionID; @@ -769,15 +768,18 @@ export default { }, - loadTiles: function(projection, dimensions, callback, loadingNotes) { + loadTiles: function(projection, dimensions, callback, noteOptions) { if (_off) return; var that = this; + var loadingNotes = (noteOptions !== undefined); // check if loading entities, or notes var path, cache, tilezoom, throttleLoadUsers; if (loadingNotes) { - path = '/api/0.6/notes?bbox='; + 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() { @@ -937,8 +939,9 @@ export default { }, - loadNotes: function(projection, dimensions) { - this.loadTiles(projection, dimensions, null, true); // true = loadingNotes + loadNotes: function(projection, dimensions, noteOptions) { + noteOptions = _extend({ limit: 10000, closed: 7}, noteOptions); + this.loadTiles(projection, dimensions, null, noteOptions); }, From e9e2f9ba8fd2d5388e6b4147c743cb3c857fd7b3 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Wed, 11 Jul 2018 15:41:43 -0400 Subject: [PATCH 58/77] Style note header --- css/65_data.css | 98 ++++++++++++++++++++++++++++++------- data/core.yaml | 1 + modules/ui/note_comments.js | 4 +- modules/ui/note_header.js | 37 +++++++++++--- 4 files changed, 114 insertions(+), 26 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 4f1c55cf6..8c32c676d 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -3,17 +3,24 @@ .layer-notes { pointer-events: none; } - .layer-notes .note .note-shadow { - color: #000; pointer-events: none; } .layer-notes .note .note-fill { - color: #ff3300; 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; +} +.note-header-icon.closed .note-fill, .layer-notes .note.closed .note-fill { color: #55dd00; } @@ -24,26 +31,79 @@ color: #ffee00; } +/* slight adjustments to preset icon for note icons */ +.note-header-icon .preset-icon-28 { + top: 18px; +} +.note-header-icon .preset-icon-24 { + top: 20px; +} +.note-header-icon .preset-icon-24 .icon.note-fill { + width: 25px; + height: 23px; +} + /* 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 10px; + 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; - padding: 10px; margin: 10px auto; display: flex; flex-flow: row nowrap; } - -.comment-main { - padding: 0 10px; - flex: 1 1 100%; - flex-flow: column nowrap; - overflow: hidden; - overflow-wrap: break-word; -} .comment-avatar { - flex: 0 0 40px; + padding: 10px; + flex: 0 0 62px; } .comment-avatar .icon.comment-avatar-icon { width: 40px; @@ -52,13 +112,16 @@ 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 { - display: -webkit-flex; /* Safari */ - -webkit-flex-wrap: wrap; /* Safari 6.1+ */ flex-flow: row nowrap; - flex-wrap: wrap; justify-content: space-between; - } .comment-author { font-weight: bold; @@ -72,7 +135,6 @@ margin-top: 10px; overflow-y: hidden; /* TODO: give scroll bar or replace with accordion */ max-height: 250px; - } #new-comment-input { diff --git a/data/core.yaml b/data/core.yaml index 3e38f40d4..02def0244 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -614,6 +614,7 @@ en: note: Note title: Edit note anonymous: anonymous + closed: "(Closed)" more: More less: Less commentTitle: Comments diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index ebb88214a..533acd74e 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -15,12 +15,12 @@ export function uiNoteComments() { function noteComments(selection) { - var comments = selection.selectAll('.comments') + var comments = selection.selectAll('.comments-container') .data([0]); comments = comments.enter() .append('div') - .attr('class', 'comments') + .attr('class', 'comments-container') .merge(comments); var commentEnter = comments.selectAll('.comment') diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js index ae0d0d875..126966388 100644 --- a/modules/ui/note_header.js +++ b/modules/ui/note_header.js @@ -7,12 +7,37 @@ export function uiNoteHeader() { function noteHeader(selection) { - selection.selectAll('.note-header') - .data([_note], function(d) { return d.id; }) - .enter() - .append('h3') - .attr('class', 'note-header') - .text(function(d) { return String(t('note.note') + ' ' + d.id); }); + var header = selection.selectAll('.note-header') + .data([_note], function(d) { return 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('#fas-comment-alt', 'note-shadow')); + + iconEnter + .append('div') + .attr('class', 'preset-icon-24') + .call(svgIcon('#fas-comment-alt', 'note-fill')); + + headerEnter + .append('div') + .attr('class', 'note-header-label') + .text(function(d) { + return t('note.note') + ' ' + d.id + ' ' + + (d.status === 'closed' ? t('note.closed') : ''); + }); } From 3454753bf607a73c867f785249d7b9d383a55893 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Wed, 11 Jul 2018 16:00:54 -0400 Subject: [PATCH 59/77] Drop more/less toggle and just use a scrollbar for long comments --- css/65_data.css | 5 ++++- data/core.yaml | 2 -- dist/locales/en.json | 7 +++--- modules/ui/note_comments.js | 45 ++----------------------------------- 4 files changed, 9 insertions(+), 50 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 8c32c676d..0be1ab163 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -133,9 +133,12 @@ .comment-text { color: #333; margin-top: 10px; - overflow-y: hidden; /* TODO: give scroll bar or replace with accordion */ + overflow-y: auto; max-height: 250px; } +.comment-text::-webkit-scrollbar { + border-left: none; +} #new-comment-input { width: 100%; diff --git a/data/core.yaml b/data/core.yaml index 02def0244..ecb21897c 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -615,8 +615,6 @@ en: title: Edit note anonymous: anonymous closed: "(Closed)" - more: More - less: Less commentTitle: Comments close: Resolve reopen: Reopen diff --git a/dist/locales/en.json b/dist/locales/en.json index 1a99cfc49..7b6591fa1 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -747,8 +747,7 @@ "note": "Note", "title": "Edit note", "anonymous": "anonymous", - "more": "More", - "less": "Less", + "closed": "(Closed)", "commentTitle": "Comments", "close": "Resolve", "reopen": "Reopen", @@ -6689,7 +6688,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "Premium DigitalGlobe satellite imagery.", + "description": "DigitalGlobe-Premium is a mosaic composed of DigitalGlobe basemap with select regions filled with +Vivid or custom area of interest imagery, 50cm resolution or better, and refreshed more frequently with ongoing updates.", "name": "DigitalGlobe Premium Imagery" }, "DigitalGlobe-Premium-vintage": { @@ -6703,7 +6702,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "Standard DigitalGlobe satellite imagery.", + "description": "DigitalGlobe-Standard is a curated set of imagery covering 86% of the earth’s landmass, with 30-60cm resolution where available, backfilled by Landsat. Average age is 2.31 years, with some areas updated 2x per year.", "name": "DigitalGlobe Standard Imagery" }, "DigitalGlobe-Standard-vintage": { diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index 533acd74e..503f4a44d 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -67,55 +67,14 @@ export function uiNoteComments() { mainEnter .append('div') - .attr('class', function(d) { - var trunc = (d.text.length > commentLimit); - return 'comment-text' + (trunc ? ' truncated' : ''); - }) - .text(function(d) { - var trunc = (d.text.length > commentLimit); - return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; - }); - - mainEnter - .each(function(d) { - var selection = d3_select(this); - var trunc = (d.text.length > commentLimit); - if (!trunc) return; - - selection - .append('a') - .attr('class', 'comment-toggle-more') - .attr('href', '#') - .attr('tabindex', -1) - .attr('target', '_blank') - .text(t('note.more')) - .on('click', toggleMore); - }); + .attr('class', 'comment-text') + .text(function(d) { return d.text; }); comments .call(replaceAvatars); } - function toggleMore() { - d3_event.preventDefault(); - - var selection = d3_select(this.parentNode); // select .comment-main - var commentText = selection.selectAll('.comment-text'); - var commentToggle = selection.selectAll('.comment-toggle-more'); - var trunc = !commentText.classed('truncated'); - - commentText - .classed('truncated', trunc) - .text(function(d) { - return trunc ? d.text.slice(0, commentLimit) + '…' : d.text; - }); - - commentToggle - .text(t('note.' + (trunc ? 'more' : 'less'))); - } - - function replaceAvatars(selection) { var osm = services.osm; if (!osm) return; From 91add0c33e7b72e2027ac5c68e54f7d6037d55de Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Wed, 11 Jul 2018 23:10:01 -0400 Subject: [PATCH 60/77] WIP on buttons, simplify, remove some event dispatch - I made the buttons work like GitHub comment-on-issue buttons before typing: "Close Note" / "Comment" (disabled) after typing: "Close and Comment" / "Comment" (enabled) - I removed a bunch of the event dispatches. These are useful for sending events to listeners/observers outside of the module. In this case I think we can handle most of the things from within the uiNoteEditor. We can still dispatch an 'update' event so that modeSelectNote can reselect and redraw the note after some change happens to it. TODO - make the buttons work / check the OSM API stuff. --- css/65_data.css | 2 +- data/core.yaml | 10 +- dist/locales/en.json | 10 +- modules/modes/select_note.js | 2 +- modules/ui/note_comments.js | 6 +- modules/ui/note_editor.js | 349 +++++++++++++++-------------------- 6 files changed, 159 insertions(+), 220 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 0be1ab163..4491570d7 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -76,7 +76,7 @@ .note-header-label { background-color: #f6f6f6; - padding: 0 10px; + padding: 0 15px; flex: 1 1 100%; font-size: 14px; font-weight: bold; diff --git a/data/core.yaml b/data/core.yaml index ecb21897c..58993ea84 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -616,15 +616,13 @@ en: anonymous: anonymous closed: "(Closed)" commentTitle: Comments - close: Resolve - reopen: Reopen newComment: New Comment inputPlaceholder: Enter a comment to share with other users. + close: Close Note + open: Reopen Note comment: Comment - commentClose: Comment & Resolve - commentReopen: Comment & Reopen - save: Save comment - cancel: Cancel + close_comment: Close and Comment + open_comment: Reopen and Comment help: title: Help key: H diff --git a/dist/locales/en.json b/dist/locales/en.json index 7b6591fa1..7c28239bc 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -749,15 +749,13 @@ "anonymous": "anonymous", "closed": "(Closed)", "commentTitle": "Comments", - "close": "Resolve", - "reopen": "Reopen", "newComment": "New Comment", "inputPlaceholder": "Enter a comment to share with other users.", + "close": "Close Note", + "open": "Reopen Note", "comment": "Comment", - "commentClose": "Comment & Resolve", - "commentReopen": "Comment & Reopen", - "save": "Save comment", - "cancel": "Cancel" + "close_comment": "Close and Comment", + "open_comment": "Reopen and Comment" }, "help": { "title": "Help", diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js index b9cf872d9..a35f087ac 100644 --- a/modules/modes/select_note.js +++ b/modules/modes/select_note.js @@ -25,7 +25,7 @@ export function modeSelectNote(context, selectedNoteID) { var osm = services.osm; var keybinding = d3_keybinding('select-note'); var noteEditor = uiNoteEditor(context) - .on('updateNote', function() { + .on('update', function() { // .call(drawNotes); // TODO: update and redraw notes var note = checkSelectedID(); if (!note) return; diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index 503f4a44d..4154f8941 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -1,7 +1,4 @@ -import { - event as d3_event, - select as d3_select -} from 'd3-selection'; +import { select as d3_select } from 'd3-selection'; import { t } from '../util/locale'; import { svgIcon } from '../svg'; @@ -10,7 +7,6 @@ import { utilDetect } from '../util/detect'; export function uiNoteComments() { - var commentLimit = 600; // add a "more" link to comments longer than this length var _note; diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 7e68df82c..ce15d8832 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -6,7 +6,6 @@ import { } from 'd3-selection'; import { t } from '../util/locale'; -import { svgIcon } from '../svg'; import { services } from '../services'; import { uiNoteComments } from './note_comments'; @@ -17,213 +16,15 @@ import { utilRebind } from '../util'; -import { utilDetect } from '../util/detect'; -import { modeBrowse } from '../modes'; - export function uiNoteEditor(context) { - // TODO: use 'toggleNote' and 'saveNote' to add 'thank you' warning to the sidebar - var dispatch = d3_dispatch('change', 'cancel', 'save', 'modifiedInput', 'updateNote', 'toggleNote', 'saveNote'); + var dispatch = d3_dispatch('update'); var noteHeader = uiNoteHeader(); var noteComments = uiNoteComments(); - var _inputValue; - var _newComment; var _note; - var _modified; function noteEditor(selection) { - render(selection); - } - - function cancel() { - _newComment = false; - context.selectedNoteID(null); - context.enter(modeBrowse(context)); - } - - function save(updateFunction) { - var osm = context.connection(); - if (!osm) { - context.enter(modeBrowse(context)); - return; - } - - // If user somehow got logged out mid-save, try to reauthenticate.. - // This can happen if they were logged in from before, but the tokens are no longer valid. - if (!osm.authenticated()) { - - // TODO: dispatch 'notAuthenticated' to give warning - - osm.authenticate(function(err) { - if (err) { // quit save mode.. - context.enter(modeBrowse(context)); - return; - } else { - save(updateFunction); // continue where we left off.. - } - }); - return; - } - - function parseResults(results) { // TODO: simplify result parsing - dispatch.call('change', results); - - // call success - if (results) { - success(results); - } - // otherwise, call failure - else { - failure(results); - } - } - - function success(results) { - console.log('success!', results); // TODO: handle success - dispatch.apply('updateNote'); - } - - function failure(results) { // TODO: handle failure & errors - console.log('failure!', results); - } - - updateFunction(parseResults); - } - - - function toggleNoteStatus(parseResults) { - if (!_note || !_note.status || !context.selectedNoteID) return; - services.osm.toggleNoteStatus(_note, _inputValue, parseResults); - } - - - function addNoteComment(parseResults) { - if (!_note || !_note.status || !context.selectedNoteID) return; - services.osm.addNoteComment(_note, _inputValue, parseResults); - } - - - function newComment(selection) { - if (!context.selectedNoteID()) return; - // New Comment - var saveSection = selection.selectAll('.save-section') - .data([0]); - - saveSection = saveSection.enter() - .append('div') - .attr('class','save-section cf') - .merge(saveSection); - - saveSection - .call(saveHeader) - .call(input) - .call(buttons); - } - - - function saveHeader(selection) { - var header = selection.selectAll('.notesSaveHeader') - .data([0]); - - header.enter() - .append('h4') - .attr('class', '.notesSaveHeader') - .text(t('note.newComment')) - .merge(header); - } - - - function input(selection) { - var input = selection.selectAll('textarea') - .data([0]); - - input.enter() - .append('textarea') - .attr('id', 'new-comment-input') - .attr('placeholder', t('note.inputPlaceholder')) - .attr('maxlength', 1000) - .call(utilNoAuto) - .on('input', change) - .on('blur', change) - .merge(input); - - - function change() { - _inputValue = this.value; - console.log(_inputValue); - dispatch.apply('modifiedInput'); - } - } - - - function buttons(selection) { - // Buttons - var buttonSection = selection.selectAll('.buttons') - .data([0]); - - // enter - var buttonEnter = buttonSection.enter() - .append('div') - .attr('class', 'buttons cf'); - - buttonEnter - .append('button') - .attr('class', 'secondary-action button cancel-button') - .append('span') - .attr('class', 'label') - .text(t('note.cancel')); - - buttonEnter - .append('button') - .attr('class', 'action button save-button') - .append('span') - .attr('class', 'label') - .text(t('note.save')); - - var status; - if (_note.status) { - status = _note.status === 'open' ? t('note.close') : t('note.reopen'); - } - - buttonEnter - .append('button') - .attr('class', _note.status + '-button status-button action button') - .append('span') - .attr('class', 'label') - .text(status); - - - // update - buttonSection = buttonSection - .merge(buttonEnter); - - buttonSection.selectAll('.closed-button,.open-button') - .on('click', function() { - save(toggleNoteStatus); - dispatch.apply('toggleNote', this); // TODO: dispatch toggleNote event - }); - - buttonSection.selectAll('.cancel-button') - .on('click.cancel', cancel); - - buttonSection.selectAll('.save-button') - .attr('disabled', function() { - var n = d3_select('#new-comment-input').node(); - return (n && n.value.length) ? null : true; - }) - .on('modifiedInput', function() { - // TODO: determine how to toggle button on input via triggering 'modifiedInput' event - }) - .on('click.save', function() { - this.blur(); // avoid keeping focus on the button - #4641 - save(addNoteComment); - dispatch.apply('saveNote', this, _newComment); // TODO: dispatch saveNote event - }); - } - - - function render(selection) { var header = selection.selectAll('.header') .data([0]); @@ -249,7 +50,153 @@ export function uiNoteEditor(context) { .attr('class', 'modal-section note-editor') .call(noteHeader.note(_note)) .call(noteComments.note(_note)) - .call(newComment); + .call(noteSave); + } + + + function noteSave(selection) { + var isSelected = (_note && _note.id === context.selectedNoteID()); + var noteSave = selection.selectAll('.note-save-section') + .data((isSelected ? [_note] : []), function(d) { return 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) + .call(utilNoAuto) + .on('input', change) + .on('blur', change); + + // update + noteSave = noteSaveEnter + .merge(noteSave); + + change(); + + function change() { + 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.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.selectAll('.status-button') + .text(function(d) { + var n = d3_select('#new-comment-input').node(); + var setStatus = (d.status === 'open' ? 'close' : 'open'); + var andComment = ((n && n.value.length) ? '_comment' : ''); + return t('note.' + setStatus + andComment); + }) + .on('click.status', function() { + this.blur(); // avoid keeping focus on the button - #4641 + // todo: the thing + }); + + buttonSection.selectAll('.comment-button') + .attr('disabled', function() { + var n = d3_select('#new-comment-input').node(); + return (n && n.value.length) ? null : true; + }) + .on('click.save', function() { + this.blur(); // avoid keeping focus on the button - #4641 + // todo: the thing + }); + } + + + function save() { + // var osm = context.connection(); + // if (!osm) { + // context.enter(modeBrowse(context)); + // return; + // } + + // // If user somehow got logged out mid-save, try to reauthenticate.. + // // This can happen if they were logged in from before, but the tokens are no longer valid. + // if (!osm.authenticated()) { + + // // TODO: dispatch 'notAuthenticated' to give warning + + // osm.authenticate(function(err) { + // if (err) { // quit save mode.. + // context.enter(modeBrowse(context)); + // return; + // } else { + // save(updateFunction); // continue where we left off.. + // } + // }); + // return; + // } + + // function parseResults(results) { // TODO: simplify result parsing + // dispatch.call('change', results); + + // // call success + // if (results) { + // success(results); + // } + // // otherwise, call failure + // else { + // failure(results); + // } + // } + + // function success(results) { + // console.log('success!', results); // TODO: handle success + // dispatch.apply('updateNote'); + // } + + // function failure(results) { // TODO: handle failure & errors + // console.log('failure!', results); + // } + + // updateFunction(parseResults); } From 5e5601f555392a058cacb98ddd4c5ff8992c1e3c Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 12 Jul 2018 02:08:16 -0400 Subject: [PATCH 61/77] Store the unsaved comment in the note itself --- modules/ui/note_editor.js | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index ce15d8832..992a2cbe8 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -1,9 +1,5 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; - -import { - event as d3_event, - select as d3_select -} from 'd3-selection'; +import { select as d3_select } from 'd3-selection'; import { t } from '../util/locale'; import { services } from '../services'; @@ -78,17 +74,29 @@ export function uiNoteEditor(context) { .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); + .merge(noteSave) + .call(noteSaveButtons); - change(); 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); } @@ -126,11 +134,10 @@ export function uiNoteEditor(context) { buttonSection = buttonSection .merge(buttonEnter); - buttonSection.selectAll('.status-button') + buttonSection.select('.status-button') // select and propagate data .text(function(d) { - var n = d3_select('#new-comment-input').node(); var setStatus = (d.status === 'open' ? 'close' : 'open'); - var andComment = ((n && n.value.length) ? '_comment' : ''); + var andComment = (d.newComment ? '_comment' : ''); return t('note.' + setStatus + andComment); }) .on('click.status', function() { @@ -138,10 +145,9 @@ export function uiNoteEditor(context) { // todo: the thing }); - buttonSection.selectAll('.comment-button') - .attr('disabled', function() { - var n = d3_select('#new-comment-input').node(); - return (n && n.value.length) ? null : true; + buttonSection.select('.comment-button') // select and propagate data + .attr('disabled', function(d) { + return d.newComment ? null : true; }) .on('click.save', function() { this.blur(); // avoid keeping focus on the button - #4641 From fe7086f75311f41d506fc646b75184b90ca694c8 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 12 Jul 2018 02:51:49 -0400 Subject: [PATCH 62/77] Add header close 'X' button, add grey hover styling --- css/80_app.css | 5 ++++- modules/ui/note_editor.js | 15 ++++++++++++--- modules/ui/sidebar.js | 8 ++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/css/80_app.css b/css/80_app.css index f6ca2de6b..e878f3af1 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; @@ -1290,6 +1292,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,7 +1332,7 @@ 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, diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 992a2cbe8..774755478 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -4,9 +4,10 @@ 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 } from './note_comments'; import { uiNoteHeader } from './note_header'; - import { utilNoAuto, utilRebind @@ -24,9 +25,17 @@ export function uiNoteEditor(context) { var header = selection.selectAll('.header') .data([0]); - header.enter() + var enter = header.enter() .append('div') - .attr('class', 'header fillL') + .attr('class', 'header fillL'); + + enter + .append('button') + .attr('class', 'fr note-editor-close') + .on('click', function() { context.enter(modeBrowse(context)); }) + .call(svgIcon('#iD-icon-close')); + + enter .append('h3') .text(t('note.title')); diff --git a/modules/ui/sidebar.js b/modules/ui/sidebar.js index 509d2c1c6..256c775f8 100644 --- a/modules/ui/sidebar.js +++ b/modules/ui/sidebar.js @@ -34,7 +34,11 @@ export function uiSidebar(context) { var notes = d3_selectAll('.note'); notes .classed('hovered', function(d) { return d === what; }); - context.ui().sidebar.show(noteEditor.note(what)); + + sidebar.show(noteEditor.note(what)); + + selection.selectAll('.sidebar-component') + .classed('inspector-hover', true); } else if (!_current && context.hasEntity(what)) { featureListWrap @@ -65,7 +69,7 @@ export function uiSidebar(context) { _wasNote = false; d3_selectAll('.note') .classed('hovered', false); - context.ui().sidebar.hide(); + sidebar.hide(); } } From 19560ebc90ccfc1ebae60ba5d94afd9bd4ccc008 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 12 Jul 2018 15:39:04 -0400 Subject: [PATCH 63/77] Properly save and restore caches when entering/leaving the walkthrough --- modules/services/osm.js | 37 ++++++++++++++++++++++--------------- modules/ui/intro/intro.js | 4 ++-- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 7ef04526d..583980f18 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'; @@ -893,9 +894,27 @@ export default { }, - loadedTiles: function(_) { - if (!arguments.length) return _tileCache.loaded; - _tileCache.loaded = _; + caches: function(obj) { + if (!arguments.length) { + return { + tile: _cloneDeep(_tileCache), + note: _cloneDeep(_noteCache), + user: _cloneDeep(_userCache) + }; + } + + if (obj.tile) { + _tileCache = obj.tile; + _tileCache.inflight = {}; + } + if (obj.note) { + _noteCache = obj.note; + _tileCache.inflight = {}; + } + if (obj.user) { + _userCache = obj.user; + } + return this; }, @@ -969,18 +988,6 @@ export default { }, - loadedNotes: function(_) { - if (!arguments.length) return _noteCache.loaded; - _noteCache.loaded = _; - return this; - }, - - - notesCache: function() { - return _noteCache; - }, - - toggleNoteStatus: function(note, comment, callback) { if (!(note instanceof osmNote) && !(this.getNote(note.id))) return; if (!this.authenticated()) return; diff --git a/modules/ui/intro/intro.js b/modules/ui/intro/intro.js index 9243c4547..3dcf67537 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); }); From 645cc790a3a488db27dd56268e17c9625833cc8e Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 12 Jul 2018 23:43:37 -0400 Subject: [PATCH 64/77] Add docs about OSM API, finish implementing `postNoteUpdate` `postNoteUpdate` can hangle status changes and comment additions (I named it that to be like `putChangeset`) Also renamed `user` to `loadUser` to be consistent with other calls --- modules/services/osm.js | 232 +++++++++++++++++++++--------------- modules/ui/note_comments.js | 2 +- 2 files changed, 139 insertions(+), 95 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 583980f18..6311c6e9b 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -28,7 +28,11 @@ import { osmWay } from '../osm'; -import { utilRebind, utilIdleWorker } from '../util'; +import { + utilRebind, + utilIdleWorker, + utilQsString +} from '../util'; var dispatch = d3_dispatch('authLoading', 'authDone', 'change', 'loading', 'loaded', 'loadedNotes'); @@ -43,10 +47,9 @@ var oauth = osmAuth({ var _blacklists = ['.*\.google(apis)?\..*/(vt|kh)[\?/].*([xyz]=.*){3}.*']; var _tileCache = { loaded: {}, inflight: {}, seen: {} }; -var _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; +var _noteCache = { loaded: {}, inflight: {}, inflightPost: {}, note: {}, rtree: rbush() }; var _userCache = { toLoad: {}, user: {} }; var _changeset = {}; -var _noteChangeset = {}; var _connectionID = 1; var _tileZoom = 16; @@ -326,14 +329,13 @@ export default { _forEach(_tileCache.inflight, abortRequest); _forEach(_noteCache.inflight, abortRequest); + _forEach(_noteCache.inflightPost, abortRequest); if (_changeset.inflight) abortRequest(_changeset.inflight); - if (_noteChangeset.inflight) abortRequest(_changeset.inflight); _tileCache = { loaded: {}, inflight: {}, seen: {} }; - _noteCache = { loaded: {}, inflight: {}, note: {}, rtree: rbush() }; + _noteCache = { loaded: {}, inflight: {}, inflightPost: {}, note: {}, rtree: rbush() }; _userCache = { toLoad: {}, user: {} }; _changeset = {}; - _noteChangeset = {}; return this; }, @@ -373,6 +375,8 @@ export default { }, + // Generic method to load data from the OSM API + // Can handle either auth or unauth calls. loadFromAPI: function(path, callback, options) { options = _extend({ skipSeen: true }, options); var that = this; @@ -421,6 +425,9 @@ 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); @@ -436,6 +443,8 @@ 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); @@ -451,8 +460,9 @@ export default { }, - // load multiple entities - // callback may be called multiple times + // 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; @@ -474,6 +484,10 @@ export default { }, + // 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) { if (_changeset.inflight) { return callback({ message: 'Changeset already inflight', status: -2 }, changeset); @@ -548,8 +562,9 @@ export default { }, - // load multiple users - // callback may be called multiple times + // 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 = []; @@ -599,7 +614,9 @@ export default { }, - user: function(uid, callback) { + // 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]; callback(undefined, _userCache.user[uid]); @@ -635,6 +652,8 @@ export default { }, + // Load the details of the logged-in user + // GET /api/0.6/user/details userDetails: function(callback) { if (_userDetails) { callback(undefined, _userDetails); @@ -671,6 +690,8 @@ export default { }, + // Load previous changesets for the logged in user + // GET /api/0.6/changesets?user=#id userChangesets: function(callback) { if (_userChangesets) { callback(undefined, _userChangesets); @@ -718,6 +739,8 @@ export default { }, + // Fetch the status of the OSM API + // GET /api/capabilities status: function(callback) { var that = this; var cid = _connectionID; @@ -757,30 +780,21 @@ export default { }, - imageryBlacklists: function() { - return _blacklists; - }, - - - tileZoom: function(_) { - if (!arguments.length) return _tileZoom; - _tileZoom = _; - return this; - }, - - + // 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; - var loadingNotes = (noteOptions !== undefined); - // check if loading entities, or notes + // 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='; + path = '/api/0.6/notes?limit=' + noteOptions.limit + '&closed=' + noteOptions.closed + '&bbox='; cache = _noteCache; tilezoom = _noteZoom; throttleLoadUsers = _throttle(function() { @@ -788,7 +802,6 @@ export default { if (!uids.length) return; that.loadUsers(uids, function() {}); // eagerly load user details }, 750); - } else { path = '/api/0.6/map?bbox='; cache = _tileCache; @@ -872,6 +885,85 @@ export default { }, + // 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 that = this; + var cid = _connectionID; + + 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 }, done); + + + function done(err, xml) { + 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 }); + } + + delete _noteCache.inflightPost[note.id]; + + if (xml) { // 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; @@ -894,6 +986,9 @@ export default { }, + // 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 { @@ -909,7 +1004,8 @@ export default { } if (obj.note) { _noteCache = obj.note; - _tileCache.inflight = {}; + _noteCache.inflight = {}; + _noteCache.inflightPost = {}; } if (obj.user) { _userCache = obj.user; @@ -958,12 +1054,19 @@ export default { }, - loadNotes: function(projection, dimensions, noteOptions) { - noteOptions = _extend({ limit: 10000, closed: 7}, noteOptions); - this.loadTiles(projection, dimensions, null, noteOptions); + 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]]; @@ -975,77 +1078,18 @@ export default { }, + // 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; - }, - - - toggleNoteStatus: function(note, comment, callback) { - if (!(note instanceof osmNote) && !(this.getNote(note.id))) return; - if (!this.authenticated()) return; - - var that = this; - var cid = _connectionID; - - function done(err, xml) { - 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 }); - } - - return callback(xml); - } - - var status = note.status === 'open' ? 'close' : 'reopen'; - - var path = '/api/0.6/notes/' + note.id + '/' + status; - path += comment ? '?text=' + comment : ''; - - _noteChangeset.inflight = oauth.xhr({ method: 'POST', path: path }, done); - - }, - - addNoteComment: function(note, comment, callback) { - if (!(note instanceof osmNote) && !(this.getNote(note.id))) return; - if (!this.authenticated()) return; - if (!comment) return; - - var that = this; - var cid = _connectionID; - - function done(err, xml) { - 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 }); - } - - return callback(xml); - } - - var path = '/api/0.6/notes/' + note.id + '/comment?text=' + comment; - - _noteChangeset.inflight = oauth.xhr({ method: 'POST', path: path }, done); - - }, + } }; diff --git a/modules/ui/note_comments.js b/modules/ui/note_comments.js index 4154f8941..a9890779c 100644 --- a/modules/ui/note_comments.js +++ b/modules/ui/note_comments.js @@ -81,7 +81,7 @@ export function uiNoteComments() { }); Object.keys(uids).forEach(function(uid) { - osm.user(uid, function(err, user) { + osm.loadUser(uid, function(err, user) { if (!user || !user.image_url) return; selection.selectAll('.comment-avatar.user-' + uid) From 57da729837cbba870ed4715ee4bfd89658872104 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 12 Jul 2018 23:50:29 -0400 Subject: [PATCH 65/77] Implement note save button code --- modules/modes/select_note.js | 4 +- modules/ui/note_editor.js | 75 +++++++++--------------------------- 2 files changed, 20 insertions(+), 59 deletions(-) diff --git a/modules/modes/select_note.js b/modules/modes/select_note.js index a35f087ac..8da727c8e 100644 --- a/modules/modes/select_note.js +++ b/modules/modes/select_note.js @@ -25,8 +25,8 @@ export function modeSelectNote(context, selectedNoteID) { var osm = services.osm; var keybinding = d3_keybinding('select-note'); var noteEditor = uiNoteEditor(context) - .on('update', function() { - // .call(drawNotes); // TODO: update and redraw notes + .on('change', function() { + context.map().pan([0,0]); // trigger a redraw var note = checkSelectedID(); if (!note) return; context.ui().sidebar diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 774755478..cde11ced9 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -15,7 +15,7 @@ import { export function uiNoteEditor(context) { - var dispatch = d3_dispatch('update'); + var dispatch = d3_dispatch('change'); var noteHeader = uiNoteHeader(); var noteComments = uiNoteComments(); var _note; @@ -145,76 +145,37 @@ export function uiNoteEditor(context) { buttonSection.select('.status-button') // select and propagate data .text(function(d) { - var setStatus = (d.status === 'open' ? 'close' : 'open'); + var action = (d.status === 'open' ? 'close' : 'open'); var andComment = (d.newComment ? '_comment' : ''); - return t('note.' + setStatus + andComment); + return t('note.' + action + andComment); }) - .on('click.status', function() { + .on('click.status', function(d) { this.blur(); // avoid keeping focus on the button - #4641 - // todo: the thing + 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.newComment ? null : true; }) - .on('click.save', function() { + .on('click.save', function(d) { this.blur(); // avoid keeping focus on the button - #4641 - // todo: the thing + var osm = services.osm; + if (osm) { + osm.postNoteUpdate(d, d.status, function(err, note) { + dispatch.call('change', note); + }); + } }); } - function save() { - // var osm = context.connection(); - // if (!osm) { - // context.enter(modeBrowse(context)); - // return; - // } - - // // If user somehow got logged out mid-save, try to reauthenticate.. - // // This can happen if they were logged in from before, but the tokens are no longer valid. - // if (!osm.authenticated()) { - - // // TODO: dispatch 'notAuthenticated' to give warning - - // osm.authenticate(function(err) { - // if (err) { // quit save mode.. - // context.enter(modeBrowse(context)); - // return; - // } else { - // save(updateFunction); // continue where we left off.. - // } - // }); - // return; - // } - - // function parseResults(results) { // TODO: simplify result parsing - // dispatch.call('change', results); - - // // call success - // if (results) { - // success(results); - // } - // // otherwise, call failure - // else { - // failure(results); - // } - // } - - // function success(results) { - // console.log('success!', results); // TODO: handle success - // dispatch.apply('updateNote'); - // } - - // function failure(results) { // TODO: handle failure & errors - // console.log('failure!', results); - // } - - // updateFunction(parseResults); - } - - noteEditor.note = function(_) { if (!arguments.length) return _note; _note = _; From 775b47272d2dc112984597a7ecab35ff2fb31a37 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Thu, 12 Jul 2018 23:56:03 -0400 Subject: [PATCH 66/77] Remove from inflight cache before doing anything in `done` --- modules/services/osm.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 6311c6e9b..870e07d29 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -933,6 +933,8 @@ export default { function done(err, xml) { + delete _noteCache.inflightPost[note.id]; + if (err) { // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. if (err.status === 400 || err.status === 401 || err.status === 403) { @@ -944,8 +946,6 @@ export default { return callback({ message: 'Connection Switched', status: -1 }); } - delete _noteCache.inflightPost[note.id]; - if (xml) { // 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; }); From 8a1079c3c652834204e874effc34a7822158cdd4 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 13 Jul 2018 00:15:30 -0400 Subject: [PATCH 67/77] Update notes on status change --- modules/svg/notes.js | 2 +- modules/ui/note_editor.js | 4 ++-- modules/ui/note_header.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/svg/notes.js b/modules/svg/notes.js index d34a0dcdd..a42b9cde7 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -71,7 +71,7 @@ export function svgNotes(projection, context, dispatch) { var data = (service ? service.notes(projection) : []); var transform = svgPointTransform(projection); var notes = layer.selectAll('.note') - .data(data, function(d) { return d.id; }); + .data(data, function(d) { return d.status + d.id; }); // exit notes.exit() diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index cde11ced9..0629fef71 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -62,7 +62,7 @@ export function uiNoteEditor(context) { function noteSave(selection) { var isSelected = (_note && _note.id === context.selectedNoteID()); var noteSave = selection.selectAll('.note-save-section') - .data((isSelected ? [_note] : []), function(d) { return d.id; }); + .data((isSelected ? [_note] : []), function(d) { return d.status + d.id; }); // exit noteSave.exit() @@ -115,7 +115,7 @@ export function uiNoteEditor(context) { function noteSaveButtons(selection) { var isSelected = (_note && _note.id === context.selectedNoteID()); var buttonSection = selection.selectAll('.buttons') - .data((isSelected ? [_note] : []), function(d) { return d.id; }); + .data((isSelected ? [_note] : []), function(d) { return d.status + d.id; }); // exit buttonSection.exit() diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js index 126966388..0b5164164 100644 --- a/modules/ui/note_header.js +++ b/modules/ui/note_header.js @@ -8,7 +8,7 @@ export function uiNoteHeader() { function noteHeader(selection) { var header = selection.selectAll('.note-header') - .data([_note], function(d) { return d.id; }); + .data([_note], function(d) { return d.status + d.id; }); header.exit() .remove(); From f825845b79a79e86d385912d86ed82366e32683c Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 13 Jul 2018 00:31:04 -0400 Subject: [PATCH 68/77] Can't skip seen on the `userDetails` call This call really does need for a result to be passed to the callback --- modules/services/osm.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 870e07d29..6fdf5d62c 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -675,7 +675,7 @@ export default { return callback({ message: 'Connection Switched', status: -1 }); } - var options = { skipSeen: true }; + var options = { skipSeen: false }; return parseXML(xml, function(err, results) { if (err) { return callback(err); From bd1586500e427603ce34d590ef74841e5df4ece4 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 13 Jul 2018 15:01:43 -0400 Subject: [PATCH 69/77] added tests for notes service --- modules/svg/notes.js | 32 ++++----- test/index.html | 2 + test/spec/osm/note.js | 15 ++++ test/spec/services/osm.js | 140 +++++++++++++++++++++++++------------- 4 files changed, 122 insertions(+), 67 deletions(-) create mode 100644 test/spec/osm/note.js diff --git a/modules/svg/notes.js b/modules/svg/notes.js index a42b9cde7..4350f85b1 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -125,24 +125,6 @@ export function svgNotes(projection, context, dispatch) { } - function toggleEdit(service, enabled) { - - function dimensions() { - return [window.innerWidth, window.innerHeight]; - } - - if (enabled) { - if (service && ~~context.map().zoom() >= minZoom) { - editOn(); - service.loadNotes(projection, dimensions()); - update(); - } else { - editOff(); - } - } - } - - function drawNotes(selection) { var enabled = svgNotes.enabled; var service = getService(); @@ -159,7 +141,19 @@ export function svgNotes(projection, context, dispatch) { .style('display', enabled ? 'block' : 'none') .merge(layer); - toggleEdit(service, enabled); + 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(_) { diff --git a/test/index.html b/test/index.html index 188b9084c..2dc8938e4 100644 --- a/test/index.html +++ b/test/index.html @@ -89,6 +89,7 @@ + @@ -115,6 +116,7 @@ + diff --git a/test/spec/osm/note.js b/test/spec/osm/note.js new file mode 100644 index 000000000..4e3d24817 --- /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 9e2832b92..929b14f69 100644 --- a/test/spec/services/osm.js +++ b/test/spec/services/osm.js @@ -137,7 +137,7 @@ describe('iD.serviceOsm', function () { }); describe('#loadFromAPI', function () { - var path = '/api/0.6/map?bbox=-74.542,40.655,-74.541,40.656'; + var path = '/aapi/0.6/map?bbox=-74.542,40.655,-74.541,40.656'; var response = '' + '' + ' ' + - '' + - '' + - '814798' + - 'https://api.openstreetmap.org/api/0.6/notes/814798' + - 'https://api.openstreetmap.org/api/0.6/notes/814798/comment' + - 'https://api.openstreetmap.org/api/0.6/notes/814798/close' + - '2016-12-13 11:02:44 UTC' + - 'open' + - '' + - '' + - '2016-12-13 11:02:44 UTC' + - 'opened' + - 'Otford Scout Hut' + - '<p>Otford Scout Hut</p>' + - '' + - '' + - '' + - ''; - - beforeEach(function() { - connection.reset(); - server = sinon.fakeServer.create(); - spy = sinon.spy(); - }); - - afterEach(function() { - server.restore(); - }); - - it('returns an object', function (done) { - connection.loadFromAPI(path, function (err, xml) { - expect(err).to.not.be.ok; - expect(typeof xml).to.eql('object'); - done(); - }); - - server.respondWith('GET', 'http://www.openstreetmap.org' + path, - [200, { 'Content-Type': 'text/xml' }, response]); - server.respond(); - }); }); @@ -584,6 +537,97 @@ 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('#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 = '' + From 33ef9a4357a76a55f573565519310bc62eb5637c Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 13 Jul 2018 15:13:24 -0400 Subject: [PATCH 70/77] Wrap callbacks with common checks for connection id and auth failure This eliminates a lot of the copy-paste code in every callback. --- modules/services/osm.js | 265 +++++++++++++++++----------------------- 1 file changed, 109 insertions(+), 156 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 6fdf5d62c..1001a202a 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -314,6 +314,25 @@ function parseXML(xml, callback, options) { } +function wrapcb(thisArg, callback, cid) { + return function(err, result) { + if (err) { + // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. + if (err.status === 400 || err.status === 401 || err.status === 403) { + thisArg.logout(); + } + return callback.call(thisArg, err); + + } else if (thisArg.getConnectionId() !== cid) { + return callback.call(thisArg, { message: 'Connection Switched', status: -1 }); + + } else { + return callback.call(thisArg, err, result); + } + }; +} + + export default { init: function() { @@ -489,68 +508,61 @@ export default { // POST /api/0.6/changeset/#id/upload // PUT /api/0.6/changeset/#id/close putChangeset: function(changeset, changes, callback) { - if (_changeset.inflight) { - return callback({ message: 'Changeset already inflight', status: -2 }, changeset); - } - - var that = this; var cid = _connectionID; - if (_changeset.open) { // reuse existing open changeset.. - createdChangeset(null, _changeset.open); - } else { // open a new changeset.. - _changeset.inflight = oauth.xhr({ + if (_changeset.inflight) { + return callback({ message: 'Changeset already inflight', status: -2 }, changeset); + + } else if (_changeset.open) { // reuse existing open changeset.. + return createdChangeset(null, _changeset.open); + + } else { // Open a new changeset.. + var options = { method: 'PUT', path: '/api/0.6/changeset/create', options: { header: { 'Content-Type': 'text/xml' } }, content: JXON.stringify(changeset.asJXON()) - }, 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', @@ -583,20 +595,15 @@ export default { 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, xml) { - 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 }); - } + if (err) { return callback(err); } var options = { skipSeen: true }; return parseXML(xml, function(err, results) { @@ -607,10 +614,6 @@ export default { } }, options); } - - _chunk(toLoad, 150).forEach(function(arr) { - oauth.xhr({ method: 'GET', path: '/api/0.6/users?users=' + arr.join() }, done); - }); }, @@ -619,24 +622,16 @@ export default { loadUser: function(uid, callback) { if (_userCache.user[uid] || !this.authenticated()) { // require auth delete _userCache.toLoad[uid]; - callback(undefined, _userCache.user[uid]); - return; + return callback(undefined, _userCache.user[uid]); } - var that = this; - var cid = _connectionID; + oauth.xhr( + { method: 'GET', path: '/api/0.6/user/' + uid }, + wrapcb(this, done, _connectionID) + ); function done(err, xml) { - 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 }); - } + if (err) { return callback(err); } var options = { skipSeen: true }; return parseXML(xml, function(err, results) { @@ -647,33 +642,23 @@ export default { } }, options); } - - oauth.xhr({ method: 'GET', path: '/api/0.6/user/' + uid }, done); }, // Load the details of the logged-in user // GET /api/0.6/user/details userDetails: function(callback) { - if (_userDetails) { - callback(undefined, _userDetails); - return; + if (_userDetails) { // retrieve cached + return callback(undefined, _userDetails); } - var that = this; - var cid = _connectionID; + oauth.xhr( + { method: 'GET', path: '/api/0.6/user/details' }, + wrapcb(this, done, _connectionID) + ); function done(err, xml) { - 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 }); - } + if (err) { return callback(err); } var options = { skipSeen: false }; return parseXML(xml, function(err, results) { @@ -685,70 +670,55 @@ export default { } }, options); } - - oauth.xhr({ method: 'GET', path: '/api/0.6/user/details' }, done); }, // 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); } - oauth.xhr({ method: 'GET', path: '/api/0.6/changesets?user=' + user.id }, done); - }); + _userChangesets = Array.prototype.map.call( + xml.getElementsByTagName('changeset'), + function (changeset) { return { tags: getTags(changeset) }; } + ).filter(function (changeset) { + var comment = changeset.tags.comment; + return comment && comment !== ''; + }); + + return callback(undefined, _userChangesets); + } }, // Fetch the status of the OSM API // GET /api/capabilities status: function(callback) { - 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'); @@ -763,20 +733,14 @@ 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); }, @@ -912,9 +876,6 @@ export default { return callback({ message: 'Note update already inflight', status: -2 }, note); } - var that = this; - var cid = _connectionID; - var action; if (note.status !== 'closed' && newStatus === 'closed') { action = 'close'; @@ -929,37 +890,29 @@ export default { path += '?' + utilQsString({ text: note.newComment }); } - _noteCache.inflightPost[note.id] = oauth.xhr({ method: 'POST', path: path }, done); + _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); } - if (err) { - // 400 Bad Request, 401 Unauthorized, 403 Forbidden.. - if (err.status === 400 || err.status === 401 || err.status === 403) { - that.logout(); + // 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]); } - return callback(err); - } - if (that.getConnectionId() !== cid) { - return callback({ message: 'Connection Switched', status: -1 }); - } - - if (xml) { // 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); - } + }, options); } }, From 1a106f5253d2c9182bb0a9cf71f9b3be31c23414 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Fri, 13 Jul 2018 15:55:58 -0400 Subject: [PATCH 71/77] Make uiViewOnOSM work for notes too, add it to note_editor footer --- ARCHITECTURE.md | 2 +- modules/osm/note.js | 5 +++++ modules/services/osm.js | 9 +++++++-- modules/ui/inspector.js | 15 +++++++++------ modules/ui/note_editor.js | 24 ++++++++++++++++++------ modules/ui/view_on_osm.js | 39 +++++++++++++++++++++++++-------------- 6 files changed, 65 insertions(+), 29 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 538530aa4..be802579e 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/modules/osm/note.js b/modules/osm/note.js index a9cd67b89..3e10e8cf1 100644 --- a/modules/osm/note.js +++ b/modules/osm/note.js @@ -51,5 +51,10 @@ _extend(osmNote.prototype, { update: function(attrs) { return osmNote(this, attrs, {v: 1 + (this.v || 0)}); + }, + + isNew: function() { + return this.id < 0; } + }); diff --git a/modules/services/osm.js b/modules/services/osm.js index 1001a202a..8ac947f5f 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -365,8 +365,8 @@ export default { }, - changesetURL: function(changesetId) { - return urlroot + '/changeset/' + changesetId; + changesetURL: function(changesetID) { + return urlroot + '/changeset/' + changesetID; }, @@ -394,6 +394,11 @@ 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) { diff --git a/modules/ui/inspector.js b/modules/ui/inspector.js index 89d1f28ed..f4785ddc3 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/note_editor.js b/modules/ui/note_editor.js index 0629fef71..dce1e6e15 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -3,11 +3,15 @@ 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 } from './note_comments'; -import { uiNoteHeader } from './note_header'; + +import { + uiNoteComments, + uiNoteHeader, + uiViewOnOSM +} from './index'; + import { utilNoAuto, utilRebind @@ -25,17 +29,17 @@ export function uiNoteEditor(context) { var header = selection.selectAll('.header') .data([0]); - var enter = header.enter() + var headerEnter = header.enter() .append('div') .attr('class', 'header fillL'); - enter + headerEnter .append('button') .attr('class', 'fr note-editor-close') .on('click', function() { context.enter(modeBrowse(context)); }) .call(svgIcon('#iD-icon-close')); - enter + headerEnter .append('h3') .text(t('note.title')); @@ -56,6 +60,14 @@ export function uiNoteEditor(context) { .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)); } diff --git a/modules/ui/view_on_osm.js b/modules/ui/view_on_osm.js index 146c95d4d..b013f158b 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); + 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; }; From 7fb42ac9d67e6b193514c7f23f0c79d2bc389e15 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Fri, 13 Jul 2018 17:09:51 -0400 Subject: [PATCH 72/77] added note reporting and margin to save buttons --- css/65_data.css | 4 ++++ css/80_app.css | 1 + data/core.yaml | 1 + dist/locales/en.json | 7 +++--- modules/ui/index.js | 1 + modules/ui/note_editor.js | 8 ++++--- modules/ui/note_report.js | 47 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 modules/ui/note_report.js diff --git a/css/65_data.css b/css/65_data.css index 4491570d7..651045e43 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -146,3 +146,7 @@ max-height: 300px; min-height: 100px; } + +.note-report { + float: right; +} \ No newline at end of file diff --git a/css/80_app.css b/css/80_app.css index e878f3af1..e421d1546 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -3573,6 +3573,7 @@ img.tile-debug { display: flex; flex-wrap: wrap; justify-content: space-around; + margin-bottom: 30px; } .save-section .buttons .action, diff --git a/data/core.yaml b/data/core.yaml index 58993ea84..199a723ab 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -623,6 +623,7 @@ en: 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 7c28239bc..b8df4e7a5 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -755,7 +755,8 @@ "open": "Reopen Note", "comment": "Comment", "close_comment": "Close and Comment", - "open_comment": "Reopen and Comment" + "open_comment": "Reopen and Comment", + "report": "Report" }, "help": { "title": "Help", @@ -6686,7 +6687,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "DigitalGlobe-Premium is a mosaic composed of DigitalGlobe basemap with select regions filled with +Vivid or custom area of interest imagery, 50cm resolution or better, and refreshed more frequently with ongoing updates.", + "description": "Premium DigitalGlobe satellite imagery.", "name": "DigitalGlobe Premium Imagery" }, "DigitalGlobe-Premium-vintage": { @@ -6700,7 +6701,7 @@ "attribution": { "text": "Terms & Feedback" }, - "description": "DigitalGlobe-Standard is a curated set of imagery covering 86% of the earth’s landmass, with 30-60cm resolution where available, backfilled by Landsat. Average age is 2.31 years, with some areas updated 2x per year.", + "description": "Standard DigitalGlobe satellite imagery.", "name": "DigitalGlobe Standard Imagery" }, "DigitalGlobe-Standard-vintage": { diff --git a/modules/ui/index.js b/modules/ui/index.js index c8caebac5..35d9a8517 100644 --- a/modules/ui/index.js +++ b/modules/ui/index.js @@ -37,6 +37,7 @@ 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/note_editor.js b/modules/ui/note_editor.js index dce1e6e15..80e6ac4c7 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -9,7 +9,8 @@ import { svgIcon } from '../svg'; import { uiNoteComments, uiNoteHeader, - uiViewOnOSM + uiNoteReport, + uiViewOnOSM, } from './index'; import { @@ -20,8 +21,8 @@ import { export function uiNoteEditor(context) { var dispatch = d3_dispatch('change'); - var noteHeader = uiNoteHeader(); var noteComments = uiNoteComments(); + var noteHeader = uiNoteHeader(); var _note; @@ -67,7 +68,8 @@ export function uiNoteEditor(context) { .enter() .append('div') .attr('class', 'footer') - .call(uiViewOnOSM(context).what(_note)); + .call(uiViewOnOSM(context).what(_note)) + .call(uiNoteReport(context).note(_note)); } diff --git a/modules/ui/note_report.js b/modules/ui/note_report.js new file mode 100644 index 000000000..759f9ba13 --- /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; +} From 7ad765d40883384e08ef1b60a9f709fb040c3045 Mon Sep 17 00:00:00 2001 From: Thomas Hervey Date: Mon, 16 Jul 2018 10:54:40 -0400 Subject: [PATCH 73/77] added simple tests for loading and retrieving notes --- modules/services/osm.js | 9 +++++++ test/index.html | 1 - test/spec/services/osm.js | 51 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/modules/services/osm.js b/modules/services/osm.js index 8ac947f5f..7e9faf49d 100644 --- a/modules/services/osm.js +++ b/modules/services/osm.js @@ -956,6 +956,15 @@ export default { }; } + // 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 = {}; diff --git a/test/index.html b/test/index.html index 2dc8938e4..602943c8d 100644 --- a/test/index.html +++ b/test/index.html @@ -116,7 +116,6 @@ - diff --git a/test/spec/services/osm.js b/test/spec/services/osm.js index 929b14f69..bc1617b4c 100644 --- a/test/spec/services/osm.js +++ b/test/spec/services/osm.js @@ -137,7 +137,7 @@ describe('iD.serviceOsm', function () { }); describe('#loadFromAPI', function () { - var path = '/aapi/0.6/map?bbox=-74.542,40.655,-74.541,40.656'; + var path = '/api/0.6/map?bbox=-74.542,40.655,-74.541,40.656'; var response = '' + '' + ' 1 ? [0] : []; }) .enter() .append('use') - .attr('class', 'note-shadow thread') - .attr('width', '18px') - .attr('height', '18px') - .attr('x', '-9px') - .attr('y', '-22px') + .attr('class', 'note-annotation thread') + .attr('width', '14px') + .attr('height', '14px') + .attr('x', '-7px') + .attr('y', '-20px') .attr('xlink:href', '#iD-icon-more'); // update diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js index 0b5164164..38525a3db 100644 --- a/modules/ui/note_header.js +++ b/modules/ui/note_header.js @@ -24,12 +24,7 @@ export function uiNoteHeader() { iconEnter .append('div') .attr('class', 'preset-icon-28') - .call(svgIcon('#fas-comment-alt', 'note-shadow')); - - iconEnter - .append('div') - .attr('class', 'preset-icon-24') - .call(svgIcon('#fas-comment-alt', 'note-fill')); + .call(svgIcon('#iD-icon-note', 'note-fill')); headerEnter .append('div') diff --git a/svg/fontawesome/fas-comment-alt.svg b/svg/fontawesome/fas-comment-alt.svg deleted file mode 100644 index a54d95df0..000000000 --- a/svg/fontawesome/fas-comment-alt.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/svg/iD-sprite/icons/icon-note.svg b/svg/iD-sprite/icons/icon-note.svg new file mode 100644 index 000000000..11033f707 --- /dev/null +++ b/svg/iD-sprite/icons/icon-note.svg @@ -0,0 +1,5 @@ + + + + + From 47de7b304fc82e380b2f2063ff7099d1bf141b99 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 16 Jul 2018 16:02:45 -0400 Subject: [PATCH 76/77] Make sure note report link doesn't show on hover --- css/80_app.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/css/80_app.css b/css/80_app.css index d14ba6147..0dc9f8754 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -1349,8 +1349,7 @@ a.hide-toggle { .inspector-hover .more-fields, .inspector-hover .form-label-button-wrap, .inspector-hover .tag-reference-button, -.inspector-hover .view-on-osm, -.inspector-hover .note-report { +.inspector-hover .footer * { opacity: 0; } From 694cc608932d44b8e67c29eb06f9727cee94d281 Mon Sep 17 00:00:00 2001 From: Bryan Housel Date: Mon, 16 Jul 2018 16:39:40 -0400 Subject: [PATCH 77/77] Add thread dots to note header icon, disable comments on closed nodes --- css/65_data.css | 13 +++++++++++++ modules/svg/notes.js | 2 +- modules/ui/note_editor.js | 2 +- modules/ui/note_header.js | 14 +++++++++++++- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/css/65_data.css b/css/65_data.css index 7dbed1a38..d672fccff 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -40,6 +40,19 @@ 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; diff --git a/modules/svg/notes.js b/modules/svg/notes.js index a8bf1a806..b416e8ee4 100644 --- a/modules/svg/notes.js +++ b/modules/svg/notes.js @@ -101,7 +101,7 @@ export function svgNotes(projection, context, dispatch) { .attr('xlink:href', '#iD-icon-note'); // add dots if there's a comment thread - notesEnter.selectAll('.thread') + notesEnter.selectAll('.note-annotation') .data(function(d) { return d.comments.length > 1 ? [0] : []; }) .enter() .append('use') diff --git a/modules/ui/note_editor.js b/modules/ui/note_editor.js index 80e6ac4c7..4a15e3adc 100644 --- a/modules/ui/note_editor.js +++ b/modules/ui/note_editor.js @@ -176,7 +176,7 @@ export function uiNoteEditor(context) { buttonSection.select('.comment-button') // select and propagate data .attr('disabled', function(d) { - return d.newComment ? null : true; + return (d.status === 'open' && d.newComment) ? null : true; }) .on('click.save', function(d) { this.blur(); // avoid keeping focus on the button - #4641 diff --git a/modules/ui/note_header.js b/modules/ui/note_header.js index 38525a3db..dcaf82d46 100644 --- a/modules/ui/note_header.js +++ b/modules/ui/note_header.js @@ -8,7 +8,10 @@ export function uiNoteHeader() { function noteHeader(selection) { var header = selection.selectAll('.note-header') - .data([_note], function(d) { return d.status + d.id; }); + .data( + (_note ? [_note] : []), + function(d) { return d.status + d.id; } + ); header.exit() .remove(); @@ -26,6 +29,15 @@ export function uiNoteHeader() { .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')