diff --git a/css/app.css b/css/app.css index 3a47ed571..058788af9 100644 --- a/css/app.css +++ b/css/app.css @@ -1433,6 +1433,14 @@ div.combobox { height:18px; } +.background-control .layer-toggle-gpx .layer-extent { + display:none; +} + +.background-control .layer-toggle-gpx.selected .layer-extent { + display:inline-block; +} + /* Geocoder */ .geocode-control, .geocode-control form { @@ -1489,9 +1497,12 @@ div.combobox { background:#000; } -#surface, #tile-g { +#surface, #layer-g, .layer-layer { position:absolute; top:0; + left: 0; + right: 0; + bottom: 0; transform-origin:0 0; -ms-transform-origin:0 0; -webkit-transform-origin:0 0; @@ -1507,10 +1518,6 @@ div.combobox { position: static; } -#tile-g { - opacity: 0.5; -} - /* About Section ------------------------------------------------------- */ diff --git a/css/map.css b/css/map.css index 18e37c010..a5823d071 100644 --- a/css/map.css +++ b/css/map.css @@ -886,6 +886,14 @@ text.point { pointer-events: visibleStroke; } +/* GPX Paths */ +path.gpx { + stroke:#6AFF25; + stroke-width:2; + fill:transparent; + pointer-events: none; +} + /* Modes */ .mode-draw-line .vertex.active, diff --git a/data/core.yaml b/data/core.yaml index 1a0e77793..4f27422ed 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -184,3 +184,6 @@ en: out: Zoom Out imagery: provided_by: "Imagery provided by {source}" + gpx: + local_layer: "Local GPX file" + drag_drop: "Drag and drop a .gpx file on the page" diff --git a/index.html b/index.html index d39868f0f..5b928ea28 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,7 @@ + @@ -41,6 +42,7 @@ + diff --git a/js/id/id.js b/js/id/id.js index 5d8af6019..d6696ca02 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -91,7 +91,8 @@ window.iD = function () { }; /* Map */ - context.background = function() { return map.background; }; + context.layers = function() { return map.layers; }; + context.background = function() { return map.layers[0]; }; context.surface = function() { return map.surface; }; context.projection = map.projection; context.tail = map.tail; @@ -124,7 +125,7 @@ window.iD = function () { var q = iD.util.stringQs(location.hash.substring(1)), detected = false; if (q.layer) { - context.background() + context.layers()[0] .source(_.find(backgroundSources, function(l) { if (l.data.sourcetag === q.layer) { detected = true; @@ -163,6 +164,8 @@ iD.detect = function() { browser.locale = navigator.language; + browser.filedrop = (window.FileReader && 'ondrop' in window); + function nav(x) { return navigator.userAgent.indexOf(x) !== -1; } diff --git a/js/id/renderer/background_source.js b/js/id/renderer/background_source.js index c3a493c6b..3ddc76477 100644 --- a/js/id/renderer/background_source.js +++ b/js/id/renderer/background_source.js @@ -2,7 +2,8 @@ iD.BackgroundSource = {}; // derive the url of a 'quadkey' style tile from a coordinate object iD.BackgroundSource.template = function(data) { - var generator = function(coord) { + + function generator(coord) { var u = ''; for (var zoom = coord[2]; zoom > 0; zoom--) { var b = 0; @@ -25,7 +26,7 @@ iD.BackgroundSource.template = function(data) { var subdomains = r.split(':')[1].split(','); return subdomains[coord[2] % subdomains.length]; }); - }; + } generator.data = data; diff --git a/js/id/renderer/localgpx.js b/js/id/renderer/localgpx.js new file mode 100644 index 000000000..cd28f6fd5 --- /dev/null +++ b/js/id/renderer/localgpx.js @@ -0,0 +1,94 @@ +iD.LocalGpx = function(context) { + var tileSize = 256, + projection, + gj = {}, + enable = true, + size = [0, 0], + transformProp = iD.util.prefixCSSProperty('Transform'), + path = d3.geo.path().projection(projection), + source = d3.functor(''); + + function render(selection) { + + path.projection(projection); + + var surf = selection.selectAll('svg') + .data(enable ? [gj] : []); + + surf.exit().remove(); + + surf.enter() + .append('svg') + .style('position', 'absolute'); + + var paths = surf + .selectAll('path') + .data(function(d) { return [d]; }); + + paths + .enter() + .append('path') + .attr('class', 'gpx'); + + paths + .attr('d', path); + } + + function toDom(x) { + return (new DOMParser()).parseFromString(x, 'text/xml'); + } + + render.projection = function(_) { + if (!arguments.length) return projection; + projection = _; + return render; + }; + + render.enable = function(_) { + if (!arguments.length) return enable; + enable = _; + return render; + }; + + render.geojson = function(_) { + if (!arguments.length) return gj; + gj = _; + return render; + }; + + render.size = function(_) { + if (!arguments.length) return size; + size = _; + return render; + }; + + render.id = 'layer-gpx'; + + function over() { + d3.event.stopPropagation(); + d3.event.preventDefault(); + d3.event.dataTransfer.dropEffect = 'copy'; + } + + d3.select('body') + .attr('dropzone', 'copy') + .on('drop.localgpx', function() { + d3.event.stopPropagation(); + d3.event.preventDefault(); + var f = d3.event.dataTransfer.files[0], + reader = new FileReader(); + + reader.onload = function(e) { + render.geojson(toGeoJSON.gpx(toDom(e.target.result))); + context.redraw(); + context.map().pan([0, 0]); + }; + + reader.readAsText(f); + }) + .on('dragenter.localgpx', over) + .on('dragexit.localgpx', over) + .on('dragover.localgpx', over); + + return render; +}; diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index ba860fe8c..4651b764e 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -11,8 +11,9 @@ iD.Map = function(context) { dblclickEnabled = true, transformStart, minzoom = 0, - background = iD.Background() - .projection(projection), + layers = [ + iD.Background().projection(projection), + iD.LocalGpx(context).projection(projection)], transformProp = iD.util.prefixCSSProperty('Transform'), points = iD.svg.Points(roundedProjection, context), vertices = iD.svg.Vertices(roundedProjection, context), @@ -21,7 +22,7 @@ iD.Map = function(context) { midpoints = iD.svg.Midpoints(roundedProjection), labels = iD.svg.Labels(roundedProjection, context), tail = iD.ui.Tail(), - surface, tilegroup; + surface, layergroup; function map(selection) { context.history() @@ -29,8 +30,8 @@ iD.Map = function(context) { selection.call(zoom); - tilegroup = selection.append('div') - .attr('id', 'tile-g'); + layergroup = selection.append('div') + .attr('id', 'layer-g'); var supersurface = selection.append('div') .style('position', 'absolute'); @@ -47,10 +48,9 @@ iD.Map = function(context) { .attr('id', 'surface') .call(iD.svg.Surface()); - map.size(selection.size()); map.surface = surface; - map.tilesurface = tilegroup; + map.layersurface = layergroup; supersurface .call(tail); @@ -131,7 +131,7 @@ iD.Map = function(context) { 'scale(' + scale + ')' + 'translate(' + tX + 'px,' + tY + 'px) '; - tilegroup.style(transformProp, transform); + layergroup.style(transformProp, transform); surface.style(transformProp, transform); queueRedraw(); @@ -142,7 +142,7 @@ iD.Map = function(context) { var prop = surface.node().style[transformProp]; if (!prop || prop === 'none') return false; surface.node().style[transformProp] = ''; - tilegroup.node().style[transformProp] = ''; + layergroup.node().style[transformProp] = ''; return true; } @@ -165,7 +165,18 @@ iD.Map = function(context) { } if (!difference) { - tilegroup.call(background); + var sel = layergroup + .selectAll('.layer-layer') + .data(layers); + + sel.exit().remove(); + + sel.enter().append('div') + .attr('class', 'layer-layer'); + + sel.each(function(layer) { + d3.select(this).call(layer); + }); } if (map.editable()) { @@ -260,7 +271,9 @@ iD.Map = function(context) { var center = map.center(); dimensions = _; surface.size(dimensions); - background.size(dimensions); + layers.map(function(l) { + l.size(dimensions); + }); projection.clipExtent([[0, 0], dimensions]); setCenter(center); return redraw(); @@ -371,7 +384,7 @@ iD.Map = function(context) { return map; }; - map.background = background; + map.layers = layers; map.projection = projection; map.redraw = redraw; diff --git a/js/id/ui/background.js b/js/id/ui/background.js index f0b3eeab0..b97ff1274 100644 --- a/js/id/ui/background.js +++ b/js/id/ui/background.js @@ -1,9 +1,13 @@ iD.ui.Background = function(context) { var event = d3.dispatch('cancel', 'save'), key = 'b', - opacities = [1, 0.5, 0]; - - var layers = context.backgroundSources(); + opacities = [1, 0.5, 0], + directions = [ + ['left', [1, 0]], + ['top', [0, -1]], + ['right', [-1, 0]], + ['bottom', [0, 1]]], + layers = context.backgroundSources(); function getSources() { var ext = context.map().extent(); @@ -15,24 +19,6 @@ iD.ui.Background = function(context) { function background(selection) { - var content = selection.append('div') - .attr('class', 'content fillD map-overlay hide'), - shown = false; - - var tooltip = bootstrap.tooltip() - .placement('right') - .html(true) - .title(iD.ui.tooltipHtml(t('background.description'), key)); - - var button = selection.append('button') - .attr('tabindex', -1) - .attr('class', 'fillD') - .on('click.background-toggle', toggle) - .call(tooltip); - - button.append('span') - .attr('class', 'layers icon'); - function toggle() { tooltip.hide(button); setVisible(content.classed('hide')); @@ -55,56 +41,23 @@ iD.ui.Background = function(context) { } } - context.surface().on('mousedown.background-outside', function() { - setVisible(false); - }); - - context.container().on('mousedown.background-outside', function() { - setVisible(false); - }); - - var opa = content - .append('div') - .attr('class', 'opacity-options-wrapper'); - - opa.append('h4') - .text(t('background.title')); - - var opacityList = opa.append('ul') - .attr('class', 'opacity-options'); - function setOpacity(d) { - context.map().tilesurface + context.map().layersurface.selectAll('.layer-layer') + .filter(function(d) { return d == context.map().layers[0]; }) .transition() .style('opacity', d) .attr('data-opacity', d); + opacityList.selectAll('li') .classed('selected', false); - d3.select(this) - .classed('selected', true); + + if (d3.event) { + d3.select(this) + .classed('selected', true); + } } - opacityList.selectAll('div.opacity') - .data(opacities) - .enter() - .append('li') - .attr('data-original-title', function(d) { - return t('background.percent_brightness', { opacity: (d * 100) }); - }) - .on('click.set-opacity', setOpacity) - .html("
") - .call(bootstrap.tooltip() - .placement('top')) - .append('div') - .attr('class', 'opacity') - .style('opacity', String); - - // Make sure there is an active selection by default - opa.select('.opacity-options li:nth-child(2)') - .classed('selected', true); - function selectLayer(d) { - content.selectAll('a.layer') .classed('selected', function(d) { return d.data.name === context.background().source().data.name; @@ -135,9 +88,16 @@ iD.ui.Background = function(context) { selectLayer(d); } - var layerList = content - .append('ul') - .attr('class', 'toggle-list fillL'); + function clickGpx(d) { + d3.event.preventDefault(); + if (!_.isEmpty(context.map().layers[1].geojson())) { + context.map().layers[1] + .enable(!context.map().layers[1].enable()); + d3.select(this) + .classed('selected', context.map().layers[1].enable()); + context.redraw(); + } + } function update() { var layerLinks = layerList.selectAll('a.layer') @@ -169,26 +129,122 @@ iD.ui.Background = function(context) { return d.data.name; }); + gpxLayerItem + .classed('selected', function() { + var gpxLayer = context.map().layers[1]; + return !_.isEmpty(gpxLayer.geojson()) && + gpxLayer.enable(); + }); + layerLinks.exit() .remove(); selectLayer(context.background().source()); } - context.map().on('move.background-update', _.debounce(update, 1000)); + function clickNudge(d) { + var interval = window.setInterval(nudge, 100); - update(); + d3.select(this).on('mouseup', function() { + window.clearInterval(interval); + nudge(); + }); + + function nudge() { + context.background().nudge(d[1], context.map().zoom()); + context.redraw(); + } + } + + var content = selection.append('div') + .attr('class', 'content fillD map-overlay hide'), + tooltip = bootstrap.tooltip() + .placement('right') + .html(true) + .title(iD.ui.tooltipHtml(t('background.description'), key)), + button = selection.append('button') + .attr('tabindex', -1) + .attr('class', 'fillD') + .on('click.background-toggle', toggle) + .call(tooltip), + opa = content + .append('div') + .attr('class', 'opacity-options-wrapper'), + shown = false; + + button.append('span') + .attr('class', 'layers icon'); + + opa.append('h4') + .text(t('background.title')); + + context.surface().on('mousedown.background-outside', function() { + setVisible(false); + }); + + context.container().on('mousedown.background-outside', function() { + setVisible(false); + }); + + var opacityList = opa.append('ul') + .attr('class', 'opacity-options'); + + opacityList.selectAll('div.opacity') + .data(opacities) + .enter() + .append('li') + .attr('data-original-title', function(d) { + return t('background.percent_brightness', { opacity: (d * 100) }); + }) + .on('click.set-opacity', setOpacity) + .html("
") + .call(bootstrap.tooltip() + .placement('top')) + .append('div') + .attr('class', 'opacity') + .style('opacity', String); + + // Make sure there is an active selection by default + opa.select('.opacity-options li:nth-child(2)') + .classed('selected', true); + + var layerList = content + .append('ul') + .attr('class', 'toggle-list fillL'); + + var gpxLayerItem = content + .append('ul') + .style('display', iD.detect().filedrop ? 'block' : 'none') + .attr('class', 'toggle-list fillL') + .append('li') + .append('a') + .classed('layer-toggle-gpx', true) + .call(bootstrap.tooltip() + .title(t('gpx.drag_drop')) + .placement('right')) + .on('click.set-gpx', clickGpx); + + gpxLayerItem + .append('span') + .attr('class', 'icon toggle'); + + gpxLayerItem.append('span') + .text(t('gpx.local_layer')); + + gpxLayerItem + .append('a') + .attr('class', 'icon geocode layer-extent') + .on('click', function() { + d3.event.preventDefault(); + d3.event.stopPropagation(); + context.map() + .extent(d3.geo.bounds(context.map().layers[1].geojson())); + }); var adjustments = content .append('div') .attr('class', 'adjustments pad1'); - var directions = [ - ['left', [1, 0]], - ['top', [0, -1]], - ['right', [-1, 0]], - ['bottom', [0, 1]]]; - adjustments.append('a') .text(t('background.fix_misalignment')) .attr('href', '#') @@ -197,8 +253,7 @@ iD.ui.Background = function(context) { .on('click', function() { var exp = d3.select(this).classed('expanded'); nudge_container.style('display', exp ? 'none' : 'block'); - d3.select(this) - .classed('expanded', !exp); + d3.select(this).classed('expanded', !exp); d3.event.preventDefault(); }); @@ -212,20 +267,7 @@ iD.ui.Background = function(context) { .append('button') .attr('class', function(d) { return d[0] + ' nudge'; }) .text(function(d) { return d[0]; }) - .on('mousedown', function(d) { - - var interval = window.setInterval(nudge, 100); - - d3.select(this).on('mouseup', function() { - window.clearInterval(interval); - nudge(); - }); - - function nudge() { - context.background().nudge(d[1], context.map().zoom()); - context.redraw(); - } - }); + .on('mousedown', clickNudge); nudge_container.append('button') .text(t('background.reset')) @@ -235,8 +277,12 @@ iD.ui.Background = function(context) { context.redraw(); }); - var keybinding = d3.keybinding('background'); + context.map() + .on('move.background-update', _.debounce(update, 1000)); + update(); + setOpacity(0.5); + var keybinding = d3.keybinding('background'); keybinding.on(key, toggle); d3.select(document) diff --git a/js/lib/togeojson.js b/js/lib/togeojson.js new file mode 100644 index 000000000..65cfdd60f --- /dev/null +++ b/js/lib/togeojson.js @@ -0,0 +1,120 @@ +toGeoJSON = (function() { + var removeSpace = (/\s*/g), trimSpace = (/^\s*|\s*$/g), splitSpace = (/\s+/); + function okhash(x) { + if (!x || !x.length) return 0; + for (var i = 0, h = 0; i < x.length; i++) { + h = ((h << 5) - h) + x.charCodeAt(i) | 0; + } return h; + } + function get(x, y) { return x.getElementsByTagName(y); } + function attr(x, y) { return x.getAttribute(y); } + function attrf(x, y) { return parseFloat(attr(x, y)); } + function get1(x, y) { var n = get(x, y); return n.length ? n[0] : null; } + function numarray(x) { + for (var j = 0, o = []; j < x.length; j++) o[j] = parseFloat(x[j]); + return o; + } + function nodeVal(x) { return x && x.firstChild && x.firstChild.nodeValue; } + function coord1(v) { return numarray(v.replace(removeSpace, '').split(',')); } + function coord(v) { + var coords = v.replace(trimSpace, '').split(splitSpace), o = []; + for (var i = 0; i < coords.length; i++) o.push(coord1(coords[i])); + return o; + } + function fc() { return { type: 'FeatureCollection', features: [] }; } + t = { + kml: function(doc, o) { + o = o || {}; + var gj = fc(), styleIndex = {}, + geotypes = ['Polygon', 'LineString', 'Point'], + placemarks = get(doc, 'Placemark'), styles = get(doc, 'Style'); + + if (o.styles) for (var k = 0; k < styles.length; k++) { + styleIndex['#' + styles[k].id] = okhash(styles[k].innerHTML).toString(16); + } + for (var j = 0; j < placemarks.length; j++) { + gj.features = gj.features.concat(getPlacemark(placemarks[j])); + } + function getGeometry(root) { + var geomNode, geomNodes, i, j, k, geoms = []; + if (get1(root, 'MultiGeometry')) return getGeometry(get1(root, 'MultiGeometry')); + for (i = 0; i < geotypes.length; i++) { + geomNodes = get(root, geotypes[i]); + if (geomNodes) { + for (j = 0; j < geomNodes.length; j++) { + geomNode = geomNodes[j]; + if (geotypes[i] == 'Point') { + geoms.push({ type: 'Point', + coordinates: coord1(nodeVal(get1(geomNode, 'coordinates'))) + }); + } else if (geotypes[i] == 'LineString') { + geoms.push({ type: 'LineString', + coordinates: coord(nodeVal(get1(geomNode, 'coordinates'))) + }); + } else if (geotypes[i] == 'Polygon') { + var rings = get(geomNode, 'LinearRing'), coords = []; + for (k = 0; k < rings.length; k++) { + coords.push(coord(nodeVal(get1(rings[k], 'coordinates')))); + } + geoms.push({ type: 'Polygon', coordinates: coords }); + } + } + } + } + return geoms; + } + function getPlacemark(root) { + var geoms = getGeometry(root), i, properties = {}, + name = nodeVal(get1(root, 'name')), + styleUrl = nodeVal(get1(root, 'styleUrl')), + description = nodeVal(get1(root, 'description')), + extendedData = get1(root, 'ExtendedData'); + + if (!geoms.length) return false; + if (name) properties.name = name; + if (styleUrl && styleIndex[styleUrl]) { + properties.styleUrl = styleUrl; + properties.styleHash = styleIndex[styleUrl]; + } + if (description) properties.description = description; + if (extendedData) { + var datas = get(extendedData, 'Data'), + simpleDatas = get(extendedData, 'SimpleData'); + + for (i = 0; i < datas.length; i++) { + properties[datas[i].getAttribute('name')] = nodeVal(get1(datas[i], 'value')); + } + for (i = 0; i < simpleDatas.length; i++) { + properties[simpleDatas[i].getAttribute('name')] = nodeVal(simpleDatas[i]); + } + } + return [{ type: 'Feature', geometry: (geoms.length === 1) ? geoms[0] : { + type: 'GeometryCollection', + geometries: geoms }, properties: properties }]; + } + return gj; + }, + gpx: function(doc, o) { + var i, j, tracks = get(doc, 'trk'), track, pt, gj = fc(); + for (i = 0; i < tracks.length; i++) { + track = tracks[i]; + var name = nodeVal(get1(track, 'name')); + var pts = get(track, 'trkpt'), line = []; + for (j = 0; j < pts.length; j++) { + line.push([attrf(pts[j], 'lon'), attrf(pts[j], 'lat')]); + } + gj.features.push({ + type: 'Feature', + properties: { + name: name || '' + }, + geometry: { type: 'LineString', coordinates: line } + }); + } + return gj; + } + }; + return t; +})(); + +if (typeof module !== 'undefined') module.exports = toGeoJSON; diff --git a/test/index.html b/test/index.html index 03d695d79..c6c1f11c7 100644 --- a/test/index.html +++ b/test/index.html @@ -31,6 +31,7 @@ + @@ -45,6 +46,7 @@ +