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