From f66535f06816ca23aaa838811fa21fe16e39ef7c Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 13:14:46 -0500 Subject: [PATCH 01/25] Fix tests --- test/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/test/index.html b/test/index.html index f8d570f36..36fe00755 100644 --- a/test/index.html +++ b/test/index.html @@ -25,6 +25,7 @@ + From 1a81a8508a3a48ea10710d935fc20833489d5bf1 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 13:21:03 -0500 Subject: [PATCH 02/25] Fix mocha globals for window event listener --- test/index.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/index.html b/test/index.html index 36fe00755..195b459f6 100644 --- a/test/index.html +++ b/test/index.html @@ -108,7 +108,10 @@ From f0e6ec66596de7d8bad6d76d5491b4441195a90b Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 13:53:52 -0500 Subject: [PATCH 03/25] Finish tests for new geo functionality --- test/spec/util.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/spec/util.js b/test/spec/util.js index 3ac1cdda2..2cfa8dc16 100644 --- a/test/spec/util.js +++ b/test/spec/util.js @@ -92,5 +92,25 @@ describe('Util', function() { expect(iD.util.geo.polygonContainsPolygon(outer, inner)).to.be.false; }); }); + + describe('#polygonIntersectsPolygon', function() { + it('says a polygon in a polygon intersects it', function() { + var outer = [[0, 0], [0, 3], [3, 3], [3, 0], [0, 0]]; + var inner = [[1, 1], [1, 2], [2, 2], [2, 1], [1, 1]]; + expect(iD.util.geo.polygonIntersectsPolygon(outer, inner)).to.be.true; + }); + + it('says a polygon that partially intersects does', function() { + var outer = [[0, 0], [0, 3], [3, 3], [3, 0], [0, 0]]; + var inner = [[-1, -1], [1, 2], [2, 2], [2, 1], [1, 1]]; + expect(iD.util.geo.polygonIntersectsPolygon(outer, inner)).to.be.true; + }); + + it('says totally disjoint polygons do not intersect', function() { + var outer = [[0, 0], [0, 3], [3, 3], [3, 0], [0, 0]]; + var inner = [[-1, -1], [-1, -2], [-2, -2], [-2, -1], [-1, -1]]; + expect(iD.util.geo.polygonIntersectsPolygon(outer, inner)).to.be.false; + }); + }); }); }); From feea5065e22a924e7f64fc33611d167c05ec0fc8 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 14:18:49 -0500 Subject: [PATCH 04/25] Do not permit values longer than 255 chars in the inspector --- js/id/ui/inspector.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/id/ui/inspector.js b/js/id/ui/inspector.js index 3af39c1b7..600538e4c 100644 --- a/js/id/ui/inspector.js +++ b/js/id/ui/inspector.js @@ -114,12 +114,14 @@ iD.ui.inspector = function() { inputs.append('input') .property('type', 'text') .attr('class', 'key') + .attr('maxlength', 255) .property('value', function(d) { return d.key; }) .on('change', function(d) { d.key = this.value; }); inputs.append('input') .property('type', 'text') .attr('class', 'value') + .attr('maxlength', 255) .property('value', function(d) { return d.value; }) .on('change', function(d) { d.value = this.value; }) .on('keydown.push-more', pushMore); @@ -271,7 +273,7 @@ iD.ui.inspector = function() { inspector.tags = function (tags) { if (!arguments.length) { - var tags = {}; + tags = {}; tagList.selectAll('li').each(function() { var row = d3.select(this), key = row.selectAll('.key').property('value'), From 9b21fae8cbaf24e0f78c85b8c115f85803d1d0d2 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 14:46:39 -0500 Subject: [PATCH 05/25] Validation. This adds basic validation and display to commits Included is * Untagged POIs, ways, areas * Tags that suggest that lines should be areas --- index.html | 1 + js/id/graph/validate.js | 47 +++++++++++++++++++++++++++++++++++++++++ js/id/id.js | 2 +- js/id/ui/commit.js | 41 +++++++++++++++++++++++++++++------ js/id/ui/save.js | 15 ++++++++++++- 5 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 js/id/graph/validate.js diff --git a/index.html b/index.html index 9db659489..2d42c9f1c 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,7 @@ + diff --git a/js/id/graph/validate.js b/js/id/graph/validate.js new file mode 100644 index 000000000..addb774ca --- /dev/null +++ b/js/id/graph/validate.js @@ -0,0 +1,47 @@ +iD.validate = function(changes) { + var warnings = [], change; + + // https://github.com/openstreetmap/josm/blob/mirror/src/org/ + // openstreetmap/josm/data/validation/tests/UnclosedWays.java#L80 + function tagSuggestsArea(change) { + if (_.isEmpty(change.tags)) return false; + var tags = change.tags; + var presence = ['landuse', 'amenities', 'tourism', 'shop']; + for (var i = 0; i < presence.length; i++) { + if (tags[presence[i]] !== undefined) { + return presence[i] + '=' + tags[presence[i]]; + } + } + if (tags.building && tags.building === 'yes') return 'building=yes'; + } + + if (changes.created.length) { + for (var i = 0; i < changes.created.length; i++) { + change = changes.created[i]; + + if (change.geometry() === 'point' && _.isEmpty(change.tags)) { + warnings.push({ + message: 'Untagged point which is not part of a line or area', + entity: change + }); + } + + if (change.geometry() === 'line' && _.isEmpty(change.tags)) { + warnings.push({ message: 'Untagged line', entity: change }); + } + + if (change.geometry() === 'area' && _.isEmpty(change.tags)) { + warnings.push({ message: 'Untagged area', entity: change }); + } + + if (change.geometry() === 'line' && tagSuggestsArea(change)) { + warnings.push({ + message: 'The tag ' + tagSuggestsArea(change) + ' suggests line should be area, but it is not and area', + entity: change + }); + } + } + } + + return warnings.length ? [warnings] : []; +}; diff --git a/js/id/id.js b/js/id/id.js index 96f4d09d5..e22a77ca6 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -96,7 +96,7 @@ window.iD = function(container) { var save_button = bar.append('button') .attr('class', 'save action wide') - .call(iD.ui.save().map(map)); + .call(iD.ui.save().map(map).controller(controller)); history.on('change.warn-unload', function() { var changes = history.changes(), diff --git a/js/id/ui/commit.js b/js/id/ui/commit.js index 547f4505f..06d072275 100644 --- a/js/id/ui/commit.js +++ b/js/id/ui/commit.js @@ -1,5 +1,5 @@ iD.ui.commit = function() { - var event = d3.dispatch('cancel', 'save'); + var event = d3.dispatch('cancel', 'save', 'fix'); function zipSame(d) { var c = [], n = -1; @@ -58,12 +58,12 @@ iD.ui.commit = function() { header.append('p').text('The changes you upload will be visible on all maps that use OpenStreetMap data.'); - var commit = body.append('div').attr('class','modal-section'); - commit.append('textarea') - .attr('class', 'changeset-comment') - .attr('placeholder', 'Brief Description of your contributions'); + var comment_section = body.append('div').attr('class','modal-section'); + comment_section.append('textarea') + .attr('class', 'changeset-comment') + .attr('placeholder', 'Brief Description of your contributions'); - var buttonwrap = commit.append('div') + var buttonwrap = comment_section.append('div') .attr('class', 'buttons'); var savebutton = buttonwrap.append('button') @@ -84,12 +84,39 @@ iD.ui.commit = function() { cancelbutton.append('span').attr('class','icon close icon-pre-text'); cancelbutton.append('span').attr('class','label').text('Cancel'); + var warnings = body.selectAll('div.warning-section') + .data(iD.validate(changes)) + .enter() + .append('div').attr('class', 'modal-section warning-section'); + + warnings.append('h3') + .text('Warnings'); + + var warning_li = warnings.append('ul') + .attr('class', 'changeset-list') + .selectAll('li') + .data(function(d) { return d; }) + .enter() + .append('li'); + + warning_li.append('button') + .attr('class', 'minor') + .on('click', event.fix) + .append('span') + .attr('class', 'icon inspect'); + + warning_li.append('strong').text(function(d) { + return d.message; + }); + var section = body.selectAll('div.commit-section') .data(['modified', 'deleted', 'created'].filter(changesLength)) .enter() .append('div').attr('class', 'commit-section modal-section fillL2'); - section.append('h3').text(String) + section.append('h3').text(function(d) { + return d.charAt(0).toUpperCase() + d.slice(1); + }) .append('small') .attr('class', 'count') .text(changesLength); diff --git a/js/id/ui/save.js b/js/id/ui/save.js index 27d6ef1e5..e40125e71 100644 --- a/js/id/ui/save.js +++ b/js/id/ui/save.js @@ -1,6 +1,6 @@ iD.ui.save = function() { - var map; + var map, controller; function save(selection) { @@ -59,6 +59,13 @@ iD.ui.save = function() { .on('cancel', function() { modal.remove(); }) + .on('fix', function(d) { + var ext = d.entity.extent(map.history().graph()); + map.extent(ext[0], ext[1]); + if (map.zoom() > 19) map.zoom(19); + controller.enter(iD.modes.Select(d.entity)); + modal.remove(); + }) .on('save', commit)); }); } else { @@ -91,5 +98,11 @@ iD.ui.save = function() { return save; }; + save.controller = function(_) { + if (!arguments.length) return controller; + controller = _; + return save; + }; + return save; }; From 9f1506af5e755b755231b6f3e8799b3fd475b315 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 09:48:21 -0800 Subject: [PATCH 06/25] Always use rounded projection --- js/id/renderer/map.js | 21 +++++++++++---------- js/id/svg.js | 1 - js/id/svg/areas.js | 6 +++--- js/id/svg/lines.js | 6 +++--- js/id/svg/midpoints.js | 4 ++-- js/id/svg/points.js | 4 ++-- js/id/svg/vertices.js | 4 ++-- test/spec/svg/areas.js | 2 +- test/spec/svg/points.js | 2 +- test/spec/svg/vertices.js | 4 ++-- 10 files changed, 27 insertions(+), 27 deletions(-) diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index 9edb347e4..467f7f351 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -5,6 +5,7 @@ iD.Map = function() { translateStart, keybinding = d3.keybinding(), projection = d3.geo.mercator().scale(1024), + roundedProjection = iD.svg.RoundProjection(projection), zoom = d3.behavior.zoom() .translate(projection.translate()) .scale(projection.scale()) @@ -16,11 +17,11 @@ iD.Map = function() { background = iD.Background() .projection(projection), transformProp = iD.util.prefixCSSProperty('Transform'), - points = iD.svg.Points(), - vertices = iD.svg.Vertices(), - lines = iD.svg.Lines(), - areas = iD.svg.Areas(), - midpoints = iD.svg.Midpoints(), + points = iD.svg.Points(roundedProjection), + vertices = iD.svg.Vertices(roundedProjection), + lines = iD.svg.Lines(roundedProjection), + areas = iD.svg.Areas(roundedProjection), + midpoints = iD.svg.Midpoints(roundedProjection), tail = d3.tail(), surface, tilegroup; @@ -97,11 +98,11 @@ iD.Map = function() { } surface - .call(points, graph, all, filter, projection) - .call(vertices, graph, all, filter, projection) - .call(lines, graph, all, filter, projection) - .call(areas, graph, all, filter, projection) - .call(midpoints, graph, all, filter, projection); + .call(points, graph, all, filter) + .call(vertices, graph, all, filter) + .call(lines, graph, all, filter) + .call(areas, graph, all, filter) + .call(midpoints, graph, all, filter); } function editOff() { diff --git a/js/id/svg.js b/js/id/svg.js index 5860126e9..e1d26e058 100644 --- a/js/id/svg.js +++ b/js/id/svg.js @@ -6,7 +6,6 @@ iD.svg = { }, PointTransform: function (projection) { - projection = iD.svg.RoundProjection(projection); return function (entity) { return 'translate(' + projection(entity.loc) + ')'; }; diff --git a/js/id/svg/areas.js b/js/id/svg/areas.js index ff24afecc..4fe06ba92 100644 --- a/js/id/svg/areas.js +++ b/js/id/svg/areas.js @@ -1,4 +1,4 @@ -iD.svg.Areas = function() { +iD.svg.Areas = function(projection) { var area_stack = { building: 0, @@ -26,7 +26,7 @@ iD.svg.Areas = function() { return as - bs; } - return function drawAreas(surface, graph, entities, filter, projection) { + return function drawAreas(surface, graph, entities, filter) { var areas = []; for (var i = 0; i < entities.length; i++) { @@ -47,7 +47,7 @@ iD.svg.Areas = function() { var nodes = _.pluck(entity.nodes, 'loc'); if (nodes.length === 0) return (lineStrings[entity.id] = ''); else return (lineStrings[entity.id] = - 'M' + nodes.map(iD.svg.RoundProjection(projection)).join('L')); + 'M' + nodes.map(projection).join('L')); } function drawPaths(group, areas, filter, classes) { diff --git a/js/id/svg/lines.js b/js/id/svg/lines.js index 3f1b634d7..4a2acfe38 100644 --- a/js/id/svg/lines.js +++ b/js/id/svg/lines.js @@ -1,4 +1,4 @@ -iD.svg.Lines = function() { +iD.svg.Lines = function(projection) { var arrowtext = '►\u3000\u3000', alength; @@ -53,7 +53,7 @@ iD.svg.Lines = function() { return paths; } - return function drawLines(surface, graph, entities, filter, projection) { + return function drawLines(surface, graph, entities, filter) { if (!alength) { var arrow = surface.append('text').text(arrowtext); @@ -80,7 +80,7 @@ iD.svg.Lines = function() { var nodes = _.pluck(entity.nodes, 'loc'); if (nodes.length === 0) return (lineStrings[entity.id] = ''); else return (lineStrings[entity.id] = - 'M' + nodes.map(iD.svg.RoundProjection(projection)).join('L')); + 'M' + nodes.map(projection).join('L')); } var casing = surface.select('.layer-casing'), diff --git a/js/id/svg/midpoints.js b/js/id/svg/midpoints.js index 9c060ea3b..321e75c8e 100644 --- a/js/id/svg/midpoints.js +++ b/js/id/svg/midpoints.js @@ -1,5 +1,5 @@ -iD.svg.Midpoints = function() { - return function drawMidpoints(surface, graph, entities, filter, projection) { +iD.svg.Midpoints = function(projection) { + return function drawMidpoints(surface, graph, entities, filter) { var midpoints = []; for (var i = 0; i < entities.length; i++) { diff --git a/js/id/svg/points.js b/js/id/svg/points.js index 7a2600814..37c4bbf91 100644 --- a/js/id/svg/points.js +++ b/js/id/svg/points.js @@ -1,4 +1,4 @@ -iD.svg.Points = function() { +iD.svg.Points = function(projection) { function imageHref(d) { // TODO: optimize for (var k in d.tags) { @@ -10,7 +10,7 @@ iD.svg.Points = function() { return 'icons/unknown.png'; } - return function drawPoints(surface, graph, entities, filter, projection) { + return function drawPoints(surface, graph, entities, filter) { var points = []; for (var i = 0; i < entities.length; i++) { diff --git a/js/id/svg/vertices.js b/js/id/svg/vertices.js index 700ef5538..ff775fa3e 100644 --- a/js/id/svg/vertices.js +++ b/js/id/svg/vertices.js @@ -1,5 +1,5 @@ -iD.svg.Vertices = function() { - return function drawVertices(surface, graph, entities, filter, projection) { +iD.svg.Vertices = function(projection) { + return function drawVertices(surface, graph, entities, filter) { var vertices = []; for (var i = 0; i < entities.length; i++) { diff --git a/test/spec/svg/areas.js b/test/spec/svg/areas.js index 54a7c1d35..5e910dbfc 100644 --- a/test/spec/svg/areas.js +++ b/test/spec/svg/areas.js @@ -12,7 +12,7 @@ describe("iD.svg.Areas", function () { var area = iD.Way({tags: {area: 'yes', building: 'yes'}}), graph = iD.Graph([area]); - surface.call(iD.svg.Areas(), graph, [area], filter, projection); + surface.call(iD.svg.Areas(projection), graph, [area], filter); expect(surface.select('.area')).to.be.classed('tag-building'); expect(surface.select('.area')).to.be.classed('tag-building-yes'); diff --git a/test/spec/svg/points.js b/test/spec/svg/points.js index 664f88a80..be06ae487 100644 --- a/test/spec/svg/points.js +++ b/test/spec/svg/points.js @@ -12,7 +12,7 @@ describe("iD.svg.Points", function () { var node = iD.Node({tags: {amenity: "cafe"}, loc: [0, 0], _poi: true}), graph = iD.Graph([node]); - surface.call(iD.svg.Points(), graph, [node], filter, projection); + surface.call(iD.svg.Points(projection), graph, [node], filter); expect(surface.select('.point')).to.be.classed('tag-amenity'); expect(surface.select('.point')).to.be.classed('tag-amenity-cafe'); diff --git a/test/spec/svg/vertices.js b/test/spec/svg/vertices.js index 760734053..dccdf80a0 100644 --- a/test/spec/svg/vertices.js +++ b/test/spec/svg/vertices.js @@ -12,7 +12,7 @@ describe("iD.svg.Vertices", function () { var node = iD.Node({tags: {highway: "traffic_signals"}, loc: [0, 0]}), graph = iD.Graph([node]); - surface.call(iD.svg.Vertices(), graph, [node], filter, projection); + surface.call(iD.svg.Vertices(projection), graph, [node], filter); expect(surface.select('.vertex')).to.be.classed('tag-highway'); expect(surface.select('.vertex')).to.be.classed('tag-highway-traffic_signals'); @@ -24,7 +24,7 @@ describe("iD.svg.Vertices", function () { way2 = iD.Way({nodes: [node.id]}), graph = iD.Graph([node, way1, way2]); - surface.call(iD.svg.Vertices(), graph, [node], filter, projection); + surface.call(iD.svg.Vertices(projection), graph, [node], filter); expect(surface.select('.vertex')).to.be.classed('shared'); }); From e24b22b518b35778c0e9344d6470bc0858637e9a Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 09:57:22 -0800 Subject: [PATCH 07/25] Extract iD.svg.LineString --- js/id/svg.js | 16 ++++++++++++++++ js/id/svg/areas.js | 12 +----------- js/id/svg/lines.js | 10 +--------- test/index.html | 1 + test/index_packaged.html | 1 + test/spec/svg.js | 10 ++++++++++ 6 files changed, 30 insertions(+), 20 deletions(-) create mode 100644 test/spec/svg.js diff --git a/js/id/svg.js b/js/id/svg.js index e1d26e058..bf73f32b8 100644 --- a/js/id/svg.js +++ b/js/id/svg.js @@ -9,5 +9,21 @@ iD.svg = { return function (entity) { return 'translate(' + projection(entity.loc) + ')'; }; + }, + + LineString: function (projection) { + var cache = {}; + return function (entity) { + if (cache[entity.id] !== undefined) { + return cache[entity.id]; + } + + if (entity.nodes.length === 0) { + return (cache[entity.id] = ''); + } + + return (cache[entity.id] = + 'M' + entity.nodes.map(function (n) { return projection(n.loc); }).join('L')); + } } }; diff --git a/js/id/svg/areas.js b/js/id/svg/areas.js index 4fe06ba92..3862aca0c 100644 --- a/js/id/svg/areas.js +++ b/js/id/svg/areas.js @@ -38,17 +38,7 @@ iD.svg.Areas = function(projection) { areas.sort(areastack); - var lineStrings = {}; - - function lineString(entity) { - if (lineStrings[entity.id] !== undefined) { - return lineStrings[entity.id]; - } - var nodes = _.pluck(entity.nodes, 'loc'); - if (nodes.length === 0) return (lineStrings[entity.id] = ''); - else return (lineStrings[entity.id] = - 'M' + nodes.map(projection).join('L')); - } + var lineString = iD.svg.LineString(projection); function drawPaths(group, areas, filter, classes) { var paths = group.selectAll('path') diff --git a/js/id/svg/lines.js b/js/id/svg/lines.js index 4a2acfe38..167a9e75e 100644 --- a/js/id/svg/lines.js +++ b/js/id/svg/lines.js @@ -73,15 +73,7 @@ iD.svg.Lines = function(projection) { lines.sort(waystack); - function lineString(entity) { - if (lineStrings[entity.id] !== undefined) { - return lineStrings[entity.id]; - } - var nodes = _.pluck(entity.nodes, 'loc'); - if (nodes.length === 0) return (lineStrings[entity.id] = ''); - else return (lineStrings[entity.id] = - 'M' + nodes.map(projection).join('L')); - } + var lineString = iD.svg.LineString(projection); var casing = surface.select('.layer-casing'), stroke = surface.select('.layer-stroke'), diff --git a/test/index.html b/test/index.html index 195b459f6..96095a244 100644 --- a/test/index.html +++ b/test/index.html @@ -151,6 +151,7 @@ + diff --git a/test/index_packaged.html b/test/index_packaged.html index 92c61df79..cf333b416 100644 --- a/test/index_packaged.html +++ b/test/index_packaged.html @@ -60,6 +60,7 @@ + diff --git a/test/spec/svg.js b/test/spec/svg.js new file mode 100644 index 000000000..bbb78c990 --- /dev/null +++ b/test/spec/svg.js @@ -0,0 +1,10 @@ +describe("iD.svg.LineString", function () { + it("returns an SVG path description for the entity's nodes", function () { + var a = iD.Node({loc: [0, 0]}), + b = iD.Node({loc: [2, 3]}), + way = iD.Way({nodes: [a, b]}), + projection = Object; + + expect(iD.svg.LineString(projection)(way)).to.equal("M0,0L2,3"); + }); +}); From f64c2df17ff81421eb9e9f9ccf2339c0153f2e52 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 10:40:14 -0800 Subject: [PATCH 08/25] Return null rather than empty string Empty string still generates the error 'Problem parsing d=""', while null results in no 'd' attribute at all. --- js/id/svg.js | 2 +- test/spec/svg.js | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/js/id/svg.js b/js/id/svg.js index bf73f32b8..e2b1dc2ad 100644 --- a/js/id/svg.js +++ b/js/id/svg.js @@ -19,7 +19,7 @@ iD.svg = { } if (entity.nodes.length === 0) { - return (cache[entity.id] = ''); + return (cache[entity.id] = null); } return (cache[entity.id] = diff --git a/test/spec/svg.js b/test/spec/svg.js index bbb78c990..265562071 100644 --- a/test/spec/svg.js +++ b/test/spec/svg.js @@ -7,4 +7,11 @@ describe("iD.svg.LineString", function () { expect(iD.svg.LineString(projection)(way)).to.equal("M0,0L2,3"); }); + + it("returns null for an entity with no nodes", function () { + var way = iD.Way(), + projection = Object; + + expect(iD.svg.LineString(projection)(way)).to.be.null; + }); }); From 8564279926257f48ef93273373a554da1a932ffc Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 10:43:04 -0800 Subject: [PATCH 09/25] Use identity projection in tests --- test/spec/svg/areas.js | 2 +- test/spec/svg/points.js | 2 +- test/spec/svg/vertices.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/spec/svg/areas.js b/test/spec/svg/areas.js index 5e910dbfc..d978e81c6 100644 --- a/test/spec/svg/areas.js +++ b/test/spec/svg/areas.js @@ -1,6 +1,6 @@ describe("iD.svg.Areas", function () { var surface, - projection = d3.geo.mercator(), + projection = Object, filter = d3.functor(true); beforeEach(function () { diff --git a/test/spec/svg/points.js b/test/spec/svg/points.js index be06ae487..6e991962c 100644 --- a/test/spec/svg/points.js +++ b/test/spec/svg/points.js @@ -1,6 +1,6 @@ describe("iD.svg.Points", function () { var surface, - projection = d3.geo.mercator(), + projection = Object, filter = d3.functor(true); beforeEach(function () { diff --git a/test/spec/svg/vertices.js b/test/spec/svg/vertices.js index dccdf80a0..8ce5e3a80 100644 --- a/test/spec/svg/vertices.js +++ b/test/spec/svg/vertices.js @@ -1,6 +1,6 @@ describe("iD.svg.Vertices", function () { var surface, - projection = d3.geo.mercator(), + projection = Object, filter = d3.functor(true); beforeEach(function () { From 545789efcc64daeaa83b2ca0821dcde74e092e50 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 10:51:51 -0800 Subject: [PATCH 10/25] More area tests --- js/id/svg/areas.js | 2 +- test/spec/svg/areas.js | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/js/id/svg/areas.js b/js/id/svg/areas.js index 3862aca0c..e77fe8a4d 100644 --- a/js/id/svg/areas.js +++ b/js/id/svg/areas.js @@ -41,7 +41,7 @@ iD.svg.Areas = function(projection) { var lineString = iD.svg.LineString(projection); function drawPaths(group, areas, filter, classes) { - var paths = group.selectAll('path') + var paths = group.selectAll('path.area') .filter(filter) .data(areas, iD.Entity.key); diff --git a/test/spec/svg/areas.js b/test/spec/svg/areas.js index d978e81c6..112e25d03 100644 --- a/test/spec/svg/areas.js +++ b/test/spec/svg/areas.js @@ -8,6 +8,16 @@ describe("iD.svg.Areas", function () { .call(iD.svg.Surface()); }); + it("adds way and area classes", function () { + var area = iD.Way({tags: {area: 'yes'}}), + graph = iD.Graph([area]); + + surface.call(iD.svg.Areas(projection), graph, [area], filter); + + expect(surface.select('path')).to.be.classed('way'); + expect(surface.select('path')).to.be.classed('area'); + }); + it("adds tag classes", function () { var area = iD.Way({tags: {area: 'yes', building: 'yes'}}), graph = iD.Graph([area]); @@ -17,4 +27,17 @@ describe("iD.svg.Areas", function () { expect(surface.select('.area')).to.be.classed('tag-building'); expect(surface.select('.area')).to.be.classed('tag-building-yes'); }); + + it("preserves non-area paths", function () { + var area = iD.Way({tags: {area: 'yes'}}), + graph = iD.Graph([area]); + + surface.select('.layer-fill') + .append('path') + .attr('class', 'other'); + + surface.call(iD.svg.Areas(projection), graph, [area], filter); + + expect(surface.selectAll('.other')[0].length).to.equal(1); + }); }); From 9a76b81125368c3380d1616d56bbdc825c7a89b7 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 11:01:20 -0800 Subject: [PATCH 11/25] First cut on multipolygon rendering --- css/map.css | 22 ++++++++++---- index.html | 1 + js/id/renderer/map.js | 2 ++ js/id/svg/multipolygons.js | 54 ++++++++++++++++++++++++++++++++++ js/id/svg/tag_classes.js | 2 +- test/index.html | 2 ++ test/index_packaged.html | 1 + test/spec/svg/multipolygons.js | 43 +++++++++++++++++++++++++++ 8 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 js/id/svg/multipolygons.js create mode 100644 test/spec/svg/multipolygons.js diff --git a/css/map.css b/css/map.css index b2929f149..fc54d7499 100644 --- a/css/map.css +++ b/css/map.css @@ -142,29 +142,37 @@ path.stroke.tag-railway-subway { stroke-dasharray: 8,8; } -path.area { +path.area, +path.multipolygon { stroke-width:2; stroke:#fff; fill:#fff; fill-opacity:0.3; } +path.multipolygon { + fill-rule: evenodd; +} + path.area.selected { stroke-width:4 !important; } -path.area.tag-natural { +path.area.tag-natural, +path.multipolygon.tag-natural { stroke: #ADD6A5; fill: #ADD6A5; stroke-width:1; } -path.area.tag-natural-water { +path.area.tag-natural-water, +path.multipolygon.tag-natural-water { stroke: #6382FF; fill: #ADBEFF; } -path.area.tag-building { +path.area.tag-building, +path.multipolygon.tag-building { stroke: #9E176A; stroke-width: 1; fill: #ff6ec7; @@ -173,7 +181,11 @@ path.area.tag-building { path.area.tag-landuse, path.area.tag-natural-wood, path.area.tag-natural-tree, -path.area.tag-natural-grassland { +path.area.tag-natural-grassland, +path.multipolygon.tag-landuse, +path.multipolygon.tag-natural-wood, +path.multipolygon.tag-natural-tree, +path.multipolygon.tag-natural-grassland { stroke: #006B34; stroke-width: 1; fill: #189E59; diff --git a/index.html b/index.html index 2d42c9f1c..4e42441f7 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,7 @@ + diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index 467f7f351..ec232d542 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -21,6 +21,7 @@ iD.Map = function() { vertices = iD.svg.Vertices(roundedProjection), lines = iD.svg.Lines(roundedProjection), areas = iD.svg.Areas(roundedProjection), + multipolygons = iD.svg.Multipolygons(roundedProjection), midpoints = iD.svg.Midpoints(roundedProjection), tail = d3.tail(), surface, tilegroup; @@ -102,6 +103,7 @@ iD.Map = function() { .call(vertices, graph, all, filter) .call(lines, graph, all, filter) .call(areas, graph, all, filter) + .call(multipolygons, graph, all, filter) .call(midpoints, graph, all, filter); } diff --git a/js/id/svg/multipolygons.js b/js/id/svg/multipolygons.js new file mode 100644 index 000000000..bad07ff41 --- /dev/null +++ b/js/id/svg/multipolygons.js @@ -0,0 +1,54 @@ +iD.svg.Multipolygons = function(projection) { + return function(surface, graph, entities, filter) { + var multipolygons = []; + + for (var i = 0; i < entities.length; i++) { + var entity = entities[i]; + if (entity.geometry() === 'relation' && entity.tags.type === 'multipolygon') { + multipolygons.push(entity); + } + } + + var lineStrings = {}; + + function lineString(entity) { + if (lineStrings[entity.id] !== undefined) { + return lineStrings[entity.id]; + } + + var multipolygon = entity.multipolygon(graph); + if (entity.members.length == 0 || !multipolygon) { + return (lineStrings[entity.id] = null); + } + + multipolygon = _.flatten(multipolygon, true); + return (lineStrings[entity.id] = + multipolygon.map(function (ring) { + return 'M' + ring.map(function (node) { return projection(node.loc); }).join('L'); + }).join("")); + } + + function drawPaths(group, multipolygons, filter, classes) { + var paths = group.selectAll('path.multipolygon') + .filter(filter) + .data(multipolygons, iD.Entity.key); + + paths.enter() + .append('path') + .attr('class', classes); + + paths + .order() + .attr('d', lineString) + .call(iD.svg.TagClasses()); + + paths.exit() + .remove(); + + return paths; + } + + var fill = surface.select('.layer-fill'), + paths = drawPaths(fill, multipolygons, filter, 'relation multipolygon'); + }; +}; diff --git a/js/id/svg/tag_classes.js b/js/id/svg/tag_classes.js index 43562fd5c..0295ca7e6 100644 --- a/js/id/svg/tag_classes.js +++ b/js/id/svg/tag_classes.js @@ -1,7 +1,7 @@ iD.svg.TagClasses = function() { var keys = iD.util.trueObj([ 'highway', 'railway', 'motorway', 'amenity', 'natural', - 'landuse', 'building', 'oneway', 'bridge' + 'landuse', 'building', 'oneway', 'bridge', 'boundary' ]), tagClassRe = /^tag-/; return function tagClassesSelection(selection) { diff --git a/test/index.html b/test/index.html index 96095a244..ab7b521cd 100644 --- a/test/index.html +++ b/test/index.html @@ -43,6 +43,7 @@ + @@ -153,6 +154,7 @@ + diff --git a/test/index_packaged.html b/test/index_packaged.html index cf333b416..9044adf66 100644 --- a/test/index_packaged.html +++ b/test/index_packaged.html @@ -62,6 +62,7 @@ + diff --git a/test/spec/svg/multipolygons.js b/test/spec/svg/multipolygons.js new file mode 100644 index 000000000..67f44ebae --- /dev/null +++ b/test/spec/svg/multipolygons.js @@ -0,0 +1,43 @@ +describe("iD.svg.Multipolygons", function () { + var surface, + projection = Object, + filter = d3.functor(true); + + beforeEach(function () { + surface = d3.select(document.createElementNS('http://www.w3.org/2000/svg', 'svg')) + .call(iD.svg.Surface()); + }); + + it("adds relation and multipolygon classes", function () { + var relation = iD.Relation({tags: {type: 'multipolygon'}}), + graph = iD.Graph([relation]); + + surface.call(iD.svg.Multipolygons(projection), graph, [relation], filter); + + expect(surface.select('path')).to.be.classed('relation'); + expect(surface.select('path')).to.be.classed('multipolygon'); + }); + + it("adds tag classes", function () { + var relation = iD.Relation({tags: {type: 'multipolygon', boundary: "administrative"}}), + graph = iD.Graph([relation]); + + surface.call(iD.svg.Multipolygons(projection), graph, [relation], filter); + + expect(surface.select('.relation')).to.be.classed('tag-boundary'); + expect(surface.select('.relation')).to.be.classed('tag-boundary-administrative'); + }); + + it("preserves non-multipolygon paths", function () { + var relation = iD.Relation({tags: {type: 'multipolygon'}}), + graph = iD.Graph([relation]); + + surface.select('.layer-fill') + .append('path') + .attr('class', 'other'); + + surface.call(iD.svg.Multipolygons(projection), graph, [relation], filter); + + expect(surface.selectAll('.other')[0].length).to.equal(1); + }); +}); From 81d4036322c0faefabc1db389a5b0cb8a0f980b0 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 15:20:16 -0500 Subject: [PATCH 12/25] Smarter contributors box, links to history when truncated. Fixes #306 --- js/id/id.js | 6 +++++- js/id/ui/contributors.js | 40 +++++++++++++++++++++++++++++++++------- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/js/id/id.js b/js/id/id.js index e22a77ca6..828aa45f9 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -156,8 +156,12 @@ window.iD = function(container) { .attr('class','about-block fillD pad1'); contributors.append('span') .attr('class', 'icon nearby icon-pre-text'); - contributors.append('pan') + contributors.append('span') .text('Viewing contributions by '); + contributors.append('span') + .attr('class', 'contributor-list'); + contributors.append('span') + .attr('class', 'contributor-count'); history.on('change.buttons', function() { var undo = history.undoAnnotation(), diff --git a/js/id/ui/contributors.js b/js/id/ui/contributors.js index 72dedf1d1..36d1cf432 100644 --- a/js/id/ui/contributors.js +++ b/js/id/ui/contributors.js @@ -3,22 +3,48 @@ iD.ui.contributors = function(map) { function contributors(selection) { var users = {}, + limit = 3, entities = map.history().graph().intersects(map.extent()); + for (var i in entities) { - if (entities[i].user) { - users[entities[i].user] = true; - if (Object.keys(users).length > 10) break; - } + if (entities[i].user) users[entities[i].user] = true; } - var u = Object.keys(users); - var l = selection.selectAll('a.user-link').data(u); + + var u = Object.keys(users), + subset = u.slice(0, limit); + + var l = selection + .select('.contributor-list') + .selectAll('a.user-link') + .data(subset); + + l.enter().append('a') .attr('class', 'user-link') - .attr('href', function(d) { return map.connection().userUrl(d); }) + .attr('href', function(d) { console.log(d); return map.connection().userUrl(d); }) .attr('target', '_blank') .text(String); + l.exit().remove(); + selection + .select('.contributor-count') + .html(''); + + if (u.length > limit) { + selection + .select('.contributor-count') + .append('a') + .attr('target', '_blank') + .attr('href', function() { + var ext = map.extent(); + return 'http://www.openstreetmap.org/browse/changesets?bbox=' + [ + ext[0][0], ext[1][1], + ext[1][0], ext[0][1]]; + }) + .text(' and ' + (u.length - limit) + ' others'); + } + if (!u.length) { selection.transition().style('opacity', 0); } else if (selection.style('opacity') === '0') { From 65946992768b5f1912c88e7df13ce3324e9964b7 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 15:24:22 -0500 Subject: [PATCH 13/25] Stray console --- js/id/ui/contributors.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/id/ui/contributors.js b/js/id/ui/contributors.js index 36d1cf432..3dc5a4014 100644 --- a/js/id/ui/contributors.js +++ b/js/id/ui/contributors.js @@ -21,7 +21,7 @@ iD.ui.contributors = function(map) { l.enter().append('a') .attr('class', 'user-link') - .attr('href', function(d) { console.log(d); return map.connection().userUrl(d); }) + .attr('href', function(d) { return map.connection().userUrl(d); }) .attr('target', '_blank') .text(String); From 5a2444b55185b08bc220f4448575e907f659bc3e Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 13:44:30 -0800 Subject: [PATCH 14/25] Extract and fix extent/intersection calculations Extents are now [[min x, min y], [max x, max y]]. --- Makefile | 2 + index.html | 3 ++ js/id/geo.js | 1 + js/id/geo/extent.js | 37 ++++++++++++++ js/id/graph/entity.js | 6 +-- js/id/graph/node.js | 2 +- js/id/graph/way.js | 7 +-- js/id/modes/select.js | 4 +- js/id/renderer/map.js | 21 ++++---- js/id/ui/geocoder.js | 2 +- js/id/ui/save.js | 3 +- test/index.html | 5 ++ test/index_packaged.html | 2 + test/spec/geo/extent.js | 100 ++++++++++++++++++++++++++++++++++++++ test/spec/graph/node.js | 4 +- test/spec/graph/way.js | 8 +-- test/spec/renderer/map.js | 13 ++--- 17 files changed, 180 insertions(+), 40 deletions(-) create mode 100644 js/id/geo.js create mode 100644 js/id/geo/extent.js create mode 100644 test/spec/geo/extent.js diff --git a/Makefile b/Makefile index 2819f2884..657915e1e 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,8 @@ all: \ js/id/oauth.js \ js/id/services/*.js \ js/id/util.js \ + js/id/geo.js \ + js/id/geo/*.js \ js/id/actions.js \ js/id/actions/*.js \ js/id/behavior.js \ diff --git a/index.html b/index.html index 4e42441f7..64d839d93 100644 --- a/index.html +++ b/index.html @@ -32,6 +32,9 @@ + + + diff --git a/js/id/geo.js b/js/id/geo.js new file mode 100644 index 000000000..06b63ae14 --- /dev/null +++ b/js/id/geo.js @@ -0,0 +1 @@ +iD.geo = {}; diff --git a/js/id/geo/extent.js b/js/id/geo/extent.js new file mode 100644 index 000000000..80bf631c6 --- /dev/null +++ b/js/id/geo/extent.js @@ -0,0 +1,37 @@ +iD.geo.Extent = function (min, max) { + if (!(this instanceof iD.geo.Extent)) return new iD.geo.Extent(min, max); + if (min instanceof iD.geo.Extent) { + return min; + } else if (min && min.length === 2 && min[0].length === 2 && min[1].length === 2) { + this[0] = min[0]; + this[1] = min[1]; + } else { + this[0] = min || [ Infinity, Infinity]; + this[1] = max || min || [-Infinity, -Infinity]; + } +}; + +iD.geo.Extent.prototype = [[], []]; + +_.extend(iD.geo.Extent.prototype, { + extend: function (obj) { + obj = iD.geo.Extent(obj); + return iD.geo.Extent([Math.min(obj[0][0], this[0][0]), + Math.min(obj[0][1], this[0][1])], + [Math.max(obj[1][0], this[1][0]), + Math.max(obj[1][1], this[1][1])]); + }, + + center: function () { + return [(this[0][0] + this[1][0]) / 2, + (this[0][1] + this[1][1]) / 2]; + }, + + intersects: function (obj) { + obj = iD.geo.Extent(obj); + return obj[0][0] <= this[1][0] && + obj[0][1] <= this[1][1] && + obj[1][0] >= this[0][0] && + obj[1][1] >= this[0][1]; + } +}); diff --git a/js/id/graph/entity.js b/js/id/graph/entity.js index afc152589..8c8f9a6fb 100644 --- a/js/id/graph/entity.js +++ b/js/id/graph/entity.js @@ -74,11 +74,7 @@ iD.Entity.prototype = { }, intersects: function(extent, resolver) { - var _extent = this.extent(resolver); - return _extent[0][0] > extent[0][0] && - _extent[1][0] < extent[1][0] && - _extent[0][1] < extent[0][1] && - _extent[1][1] > extent[1][1]; + return this.extent(resolver).intersects(extent); }, hasInterestingTags: function() { diff --git a/js/id/graph/node.js b/js/id/graph/node.js index 9500991fa..fd3a1cd69 100644 --- a/js/id/graph/node.js +++ b/js/id/graph/node.js @@ -2,7 +2,7 @@ iD.Node = iD.Entity.extend({ type: "node", extent: function() { - return [this.loc, this.loc]; + return iD.geo.Extent(this.loc); }, geometry: function() { diff --git a/js/id/graph/way.js b/js/id/graph/way.js index 5c3e1210b..305f12051 100644 --- a/js/id/graph/way.js +++ b/js/id/graph/way.js @@ -4,14 +4,11 @@ iD.Way = iD.Entity.extend({ extent: function(resolver) { return resolver.transient(this, 'extent', function() { - var extent = [[-Infinity, Infinity], [Infinity, -Infinity]]; + var extent = iD.geo.Extent(); for (var i = 0, l = this.nodes.length; i < l; i++) { var node = this.nodes[i]; if (node.loc === undefined) node = resolver.entity(node); - if (node.loc[0] > extent[0][0]) extent[0][0] = node.loc[0]; - if (node.loc[0] < extent[1][0]) extent[1][0] = node.loc[0]; - if (node.loc[1] < extent[0][1]) extent[0][1] = node.loc[1]; - if (node.loc[1] > extent[1][1]) extent[1][1] = node.loc[1]; + extent = extent.extend(node.loc); } return extent; }); diff --git a/js/id/modes/select.js b/js/id/modes/select.js index d091fdc57..4d567fd59 100644 --- a/js/id/modes/select.js +++ b/js/id/modes/select.js @@ -59,8 +59,8 @@ iD.modes.Select = function (entity) { map_size = mode.map.size(), entity_extent = entity.extent(mode.history.graph()), left_edge = map_size[0] - inspector_size[0], - left = mode.map.projection(entity_extent[1])[0], - right = mode.map.projection(entity_extent[0])[0]; + left = mode.map.projection(entity_extent[0])[0], + right = mode.map.projection(entity_extent[1])[0]; if (left > left_edge && right > left_edge) mode.map.centerEase( diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index ec232d542..59f46f0ee 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -257,26 +257,23 @@ iD.Map = function() { }, 20); }; - map.extent = function(tl, br) { + map.extent = function(_) { if (!arguments.length) { - return [projection.invert([0, 0]), projection.invert(dimensions)]; + return iD.geo.Extent(projection.invert([0, dimensions[1]]), + projection.invert([dimensions[0], 0])); } else { - - var TL = projection(tl), - BR = projection(br); + var extent = iD.geo.Extent(_), + tl = projection([extent[0][0], extent[1][1]]), + br = projection([extent[1][0], extent[0][1]]); // Calculate maximum zoom that fits extent - var hFactor = (BR[0] - TL[0]) / dimensions[0], - vFactor = (BR[1] - TL[1]) / dimensions[1], + var hFactor = (br[0] - tl[0]) / dimensions[0], + vFactor = (br[1] - tl[1]) / dimensions[1], hZoomDiff = Math.log(Math.abs(hFactor)) / Math.LN2, vZoomDiff = Math.log(Math.abs(vFactor)) / Math.LN2, newZoom = map.zoom() - Math.max(hZoomDiff, vZoomDiff); - // Calculate center of projected extent - var midPoint = [(TL[0] + BR[0]) / 2, (TL[1] + BR[1]) / 2], - midLoc = projection.invert(midPoint); - - map.zoom(newZoom).center(midLoc); + map.zoom(newZoom).center(extent.center()); } }; diff --git a/js/id/ui/geocoder.js b/js/id/ui/geocoder.js index 87eaa7d74..4bd8c0629 100644 --- a/js/id/ui/geocoder.js +++ b/js/id/ui/geocoder.js @@ -16,7 +16,7 @@ iD.ui.geocoder = function() { .text('No location found for "' + resp.query[0] + '"'); } var bounds = resp.results[0][0].bounds; - map.extent([bounds[0], bounds[3]], [bounds[2], bounds[1]]); + map.extent(iD.geo.Extent([bounds[0], bounds[1]], [bounds[2], bounds[3]])); }); } diff --git a/js/id/ui/save.js b/js/id/ui/save.js index e40125e71..6bf8419c3 100644 --- a/js/id/ui/save.js +++ b/js/id/ui/save.js @@ -60,8 +60,7 @@ iD.ui.save = function() { modal.remove(); }) .on('fix', function(d) { - var ext = d.entity.extent(map.history().graph()); - map.extent(ext[0], ext[1]); + map.extent(d.entity.extent(map.history().graph())); if (map.zoom() > 19) map.zoom(19); controller.enter(iD.modes.Select(d.entity)); modal.remove(); diff --git a/test/index.html b/test/index.html index ab7b521cd..7f63be2a3 100644 --- a/test/index.html +++ b/test/index.html @@ -34,6 +34,9 @@ + + + @@ -139,6 +142,8 @@ + + diff --git a/test/index_packaged.html b/test/index_packaged.html index 9044adf66..042a0227e 100644 --- a/test/index_packaged.html +++ b/test/index_packaged.html @@ -47,6 +47,8 @@ + + diff --git a/test/spec/geo/extent.js b/test/spec/geo/extent.js new file mode 100644 index 000000000..54f328efb --- /dev/null +++ b/test/spec/geo/extent.js @@ -0,0 +1,100 @@ +describe("iD.geo.Extent", function () { + describe("constructor", function () { + it("defaults to infinitely empty extent", function () { + expect(iD.geo.Extent()).to.eql([[Infinity, Infinity], [-Infinity, -Infinity]]); + }); + + it("constructs via a point", function () { + var p = [0, 0]; + expect(iD.geo.Extent(p)).to.eql([p, p]); + }); + + it("constructs via two points", function () { + var min = [0, 0], + max = [5, 10]; + expect(iD.geo.Extent(min, max)).to.eql([min, max]); + }); + + it("constructs via an extent", function () { + var min = [0, 0], + max = [5, 10]; + expect(iD.geo.Extent([min, max])).to.eql([min, max]); + }); + + it("constructs via an iD.geo.Extent", function () { + var min = [0, 0], + max = [5, 10], + extent = iD.geo.Extent(min, max); + expect(iD.geo.Extent(extent)).to.equal(extent); + }); + + it("has length 2", function () { + expect(iD.geo.Extent().length).to.equal(2); + }); + + it("has min element", function () { + var min = [0, 0], + max = [5, 10]; + expect(iD.geo.Extent(min, max)[0]).to.equal(min); + }); + + it("has max element", function () { + var min = [0, 0], + max = [5, 10]; + expect(iD.geo.Extent(min, max)[1]).to.equal(max); + }); + }); + + describe("#center", function () { + it("returns the center point", function () { + expect(iD.geo.Extent([0, 0], [5, 10]).center()).to.eql([2.5, 5]); + }); + }); + + describe("#extend", function () { + it("does not modify self", function () { + var extent = iD.geo.Extent([0, 0], [0, 0]); + extent.extend([1, 1]); + expect(extent).to.eql([[0, 0], [0, 0]]); + }); + + it("returns the minimal extent containing self and the given point", function () { + expect(iD.geo.Extent().extend([0, 0])).to.eql([[0, 0], [0, 0]]); + expect(iD.geo.Extent([0, 0], [0, 0]).extend([5, 10])).to.eql([[0, 0], [5, 10]]); + }); + + it("returns the minimal extent containing self and the given extent", function () { + expect(iD.geo.Extent().extend([[0, 0], [5, 10]])).to.eql([[0, 0], [5, 10]]); + expect(iD.geo.Extent([0, 0], [0, 0]).extend([[4, -1], [5, 10]])).to.eql([[0, -1], [5, 10]]); + }); + }); + + describe('#intersects', function () { + it("returns true for a point inside self", function () { + expect(iD.geo.Extent([0, 0], [5, 5]).intersects([2, 2])).to.be.true; + }); + + it("returns true for a point on the boundary of self", function () { + expect(iD.geo.Extent([0, 0], [5, 5]).intersects([0, 0])).to.be.true; + }); + + it("returns false for a point outside self", function () { + expect(iD.geo.Extent([0, 0], [5, 5]).intersects([6, 6])).to.be.false; + }); + + it("returns true for an extent contained by self", function () { + expect(iD.geo.Extent([0, 0], [5, 5]).intersects([[1, 1], [2, 2]])).to.be.true; + expect(iD.geo.Extent([1, 1], [2, 2]).intersects([[0, 0], [5, 5]])).to.be.true; + }); + + it("returns true for an extent intersected by self", function () { + expect(iD.geo.Extent([0, 0], [5, 5]).intersects([[1, 1], [6, 6]])).to.be.true; + expect(iD.geo.Extent([1, 1], [6, 6]).intersects([[0, 0], [5, 5]])).to.be.true; + }); + + it("returns false for an extent not intersected by self", function () { + expect(iD.geo.Extent([0, 0], [5, 5]).intersects([[6, 6], [7, 7]])).to.be.false; + expect(iD.geo.Extent([[6, 6], [7, 7]]).intersects([[0, 0], [5, 5]])).to.be.false; + }); + }); +}); diff --git a/test/spec/graph/node.js b/test/spec/graph/node.js index 929946b04..8045270b3 100644 --- a/test/spec/graph/node.js +++ b/test/spec/graph/node.js @@ -29,11 +29,11 @@ describe('iD.Node', function () { describe("#intersects", function () { it("returns true for a node within the given extent", function () { - expect(iD.Node({loc: [0, 0]}).intersects([[-180, 90], [180, -90]])).to.equal(true); + expect(iD.Node({loc: [0, 0]}).intersects([[-5, -5], [5, 5]])).to.equal(true); }); it("returns false for a node outside the given extend", function () { - expect(iD.Node({loc: [0, 0]}).intersects([[100, 90], [180, -90]])).to.equal(false); + expect(iD.Node({loc: [6, 6]}).intersects([[-5, -5], [5, 5]])).to.equal(false); }); }); diff --git a/test/spec/graph/way.js b/test/spec/graph/way.js index 3aa8045fb..19a4514db 100644 --- a/test/spec/graph/way.js +++ b/test/spec/graph/way.js @@ -41,7 +41,7 @@ describe('iD.Way', function() { node2 = iD.Node({loc: [5, 10]}), way = iD.Way({nodes: [node1.id, node2.id]}), graph = iD.Graph([node1, node2, way]); - expect(way.extent(graph)).to.eql([[5, 0], [0, 10]]); + expect(way.extent(graph)).to.eql([[0, 0], [5, 10]]); }); }); @@ -50,14 +50,14 @@ describe('iD.Way', function() { var node = iD.Node({loc: [0, 0]}), way = iD.Way({nodes: [node.id]}), graph = iD.Graph([node, way]); - expect(way.intersects([[-180, 90], [180, -90]], graph)).to.equal(true); + expect(way.intersects([[-5, -5], [5, 5]], graph)).to.equal(true); }); it("returns false for way with no nodes within the given extent", function () { - var node = iD.Node({loc: [0, 0]}), + var node = iD.Node({loc: [6, 6]}), way = iD.Way({nodes: [node.id]}), graph = iD.Graph([node, way]); - expect(way.intersects([[100, 90], [180, -90]], graph)).to.equal(false); + expect(way.intersects([[-5, -5], [5, 5]], graph)).to.equal(false); }); }); diff --git a/test/spec/renderer/map.js b/test/spec/renderer/map.js index 4cec560e9..611a2d1ff 100644 --- a/test/spec/renderer/map.js +++ b/test/spec/renderer/map.js @@ -54,16 +54,17 @@ describe('Map', function() { describe('#extent', function() { it('gets and sets extent', function() { - expect(map.size([100, 100])).to.equal(map); - expect(map.center([0, 0])).to.equal(map); + map.size([100, 100]) + .center([0, 0]); + expect(map.extent()[0][0]).to.be.closeTo(-17.5, 0.5); expect(map.extent()[1][0]).to.be.closeTo(17.5, 0.5); - expect(map.extent([10, 1], [30, 1])); + expect(map.extent([[10, 1], [30, 1]])); expect(map.extent()[0][0]).to.be.closeTo(10, 0.1); expect(map.extent()[1][0]).to.be.closeTo(30, 0.1); - expect(map.extent([-1, -20], [1, -40])); - expect(map.extent()[0][1]).to.be.closeTo(-20, 0.1); - expect(map.extent()[1][1]).to.be.closeTo(-40, 0.1); + expect(map.extent([[-1, -40], [1, -20]])); + expect(map.extent()[0][1]).to.be.closeTo(-40, 1); + expect(map.extent()[1][1]).to.be.closeTo(-20, 1); }); }); From 9abe3af312554470a0d79a8f75e3f1587a08cb7d Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 14:14:31 -0800 Subject: [PATCH 15/25] Fix extent and difference rendering for multi polygons --- js/id/graph/relation.js | 10 ++++++++-- js/id/renderer/map.js | 10 ++++++++-- test/spec/graph/relation.js | 9 ++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/js/id/graph/relation.js b/js/id/graph/relation.js index 4788ad0d1..490b98ac7 100644 --- a/js/id/graph/relation.js +++ b/js/id/graph/relation.js @@ -2,8 +2,14 @@ iD.Relation = iD.Entity.extend({ type: "relation", members: [], - extent: function() { - return [[NaN, NaN], [NaN, NaN]]; + extent: function(resolver) { + return resolver.transient(this, 'extent', function() { + var extent = iD.geo.Extent(); + for (var i = 0, l = this.members.length; i < l; i++) { + extent = extent.extend(resolver.entity(this.members[i].id).extent(resolver)); + } + return extent; + }); }, geometry: function() { diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index 59f46f0ee..a21a8a2d0 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -69,8 +69,7 @@ iD.Map = function() { all = graph.intersects(extent); filter = d3.functor(true); } else { - var only = {}, - filterOnly = {}; + var only = {}; for (var j = 0; j < difference.length; j++) { var id = difference[j], entity = graph.fetch(id); @@ -86,6 +85,13 @@ iD.Map = function() { only[parents[k].id] = graph.fetch(parents[k].id); } } + parents = graph.parentRelations(only[id]); + for (k = 0; k < parents.length; k++) { + // Don't re-fetch parents + if (only[parents[k].id] === undefined) { + only[parents[k].id] = parents[k].id; + } + } } } } diff --git a/test/spec/graph/relation.js b/test/spec/graph/relation.js index 26a1a2022..7395521c0 100644 --- a/test/spec/graph/relation.js +++ b/test/spec/graph/relation.js @@ -36,7 +36,14 @@ describe('iD.Relation', function () { }); describe("#extent", function () { - it("returns the minimal extent containing the extents of all members"); + it("returns the minimal extent containing the extents of all members", function () { + var a = iD.Node({loc: [0, 0]}), + b = iD.Node({loc: [5, 10]}), + r = iD.Relation({members: [{id: a.id}, {id: b.id}]}), + graph = iD.Graph([a, b, r]); + + expect(r.extent(graph)).to.eql([[0, 0], [5, 10]]) + }); }); describe("#multipolygon", function () { From 8c051dc80182d84167bc55668f639d5e98afcff8 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 18:05:48 -0500 Subject: [PATCH 16/25] Add centerZoom to center and zoom the map with one redraw. Fixes #416 Also throttles redraw and make hash track the map faster. --- js/id/id.js | 3 +-- js/id/renderer/hash.js | 12 ++++++----- js/id/renderer/map.js | 46 +++++++++++++++++++++++++++--------------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/js/id/id.js b/js/id/id.js index 828aa45f9..afcdfc3e4 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -209,8 +209,7 @@ window.iD = function(container) { var hash = iD.Hash().map(map); if (!hash.hadHash) { - map.zoom(20) - .center([-77.02271,38.90085]); + map.centerZoom([-77.02271, 38.90085], 20); } d3.select('.user-container').call(iD.ui.userpanel(connection) diff --git a/js/id/renderer/hash.js b/js/id/renderer/hash.js index 05691ef59..bb454811b 100644 --- a/js/id/renderer/hash.js +++ b/js/id/renderer/hash.js @@ -1,5 +1,5 @@ iD.Hash = function() { - var hash = {}, + var hash = { hadHash: false }, s0, // cached location.hash lat = 90 - 1e-8, // allowable latitude range map; @@ -10,8 +10,9 @@ iD.Hash = function() { if (args.length < 3 || args.some(isNaN)) { return true; // replace bogus hash } else { - map.zoom(args[0]) - .center([args[2], Math.min(lat, Math.max(-lat, args[1]))]); + map.centerZoom([args[2], + Math.min(lat, Math.max(-lat, args[1]))], + args[0]); } }; @@ -27,12 +28,13 @@ iD.Hash = function() { var move = _.throttle(function() { var s1 = formatter(map); if (s0 !== s1) location.replace(s0 = s1); // don't recenter the map! - }, 1000); + }, 100); function hashchange() { if (location.hash === s0) return; // ignore spurious hashchange events - if (parser(map, (s0 = location.hash).substring(2))) + if (parser(map, (s0 = location.hash).substring(2))) { move(); // replace bogus hash + } } hash.map = function(x) { diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index a21a8a2d0..75043a1f8 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -161,7 +161,7 @@ iD.Map = function() { redraw(); } - function redraw(difference) { + var redraw = _.throttle(function(difference) { dispatch.move(map); surface.attr('data-zoom', ~~map.zoom()); tilegroup.call(background); @@ -172,7 +172,7 @@ iD.Map = function() { editOff(); } return map; - } + }, 10); function pointLocation(p) { var translate = projection.translate(), @@ -207,10 +207,7 @@ iD.Map = function() { return map; }; - map.zoom = function(z) { - if (!arguments.length) { - return Math.max(Math.log(projection.scale()) / Math.LN2 - 8, 0); - } + function setZoom(z) { var scale = 256 * Math.pow(2, z), center = pxCenter(), l = pointLocation(center); @@ -223,8 +220,17 @@ iD.Map = function() { t[1] += center[1] - l[1]; projection.translate(t); zoom.translate(projection.translate()); - return redraw(); - }; + } + + function setCenter(loc) { + var t = projection.translate(), + c = pxCenter(), + ll = projection(loc); + projection.translate([ + t[0] - ll[0] + c[0], + t[1] - ll[1] + c[1]]); + zoom.translate(projection.translate()); + } map.size = function(_) { if (!arguments.length) return dimensions; @@ -244,17 +250,25 @@ iD.Map = function() { if (!arguments.length) { return projection.invert(pxCenter()); } else { - var t = projection.translate(), - c = pxCenter(), - ll = projection(loc); - projection.translate([ - t[0] - ll[0] + c[0], - t[1] - ll[1] + c[1]]); - zoom.translate(projection.translate()); + setCenter(loc); return redraw(); } }; + map.zoom = function(z) { + if (!arguments.length) { + return Math.max(Math.log(projection.scale()) / Math.LN2 - 8, 0); + } + setZoom(z); + return redraw(); + }; + + map.centerZoom = function(loc, z) { + setCenter(loc); + setZoom(z); + return redraw(); + }; + map.centerEase = function(loc) { var from = map.center().slice(), t = 0; d3.timer(function() { @@ -279,7 +293,7 @@ iD.Map = function() { vZoomDiff = Math.log(Math.abs(vFactor)) / Math.LN2, newZoom = map.zoom() - Math.max(hZoomDiff, vZoomDiff); - map.zoom(newZoom).center(extent.center()); + map.centerZoom(extent.center(), newZoom); } }; From 7df1a00f3853d36516155abaf5df3a34dd622b3f Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 18:13:16 -0500 Subject: [PATCH 17/25] Fix hash tests --- test/spec/renderer/hash.js | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/test/spec/renderer/hash.js b/test/spec/renderer/hash.js index 64945683d..44d3c64a2 100644 --- a/test/spec/renderer/hash.js +++ b/test/spec/renderer/hash.js @@ -7,7 +7,8 @@ describe("hash", function () { on: function () { return map; }, off: function () { return map; }, zoom: function () { return arguments.length ? map : 0; }, - center: function () { return arguments.length ? map : [0, 0] } + center: function () { return arguments.length ? map : [0, 0] }, + centerZoom: function () { return arguments.length ? map : [0, 0] } }; }); @@ -28,18 +29,11 @@ describe("hash", function () { expect(hash.hadHash).to.be.true; }); - it("zooms map to requested level", function () { + it("centerZooms map to requested level", function () { location.hash = "?map=20.00/38.87952/-77.02405"; - sinon.spy(map, 'zoom'); + sinon.spy(map, 'centerZoom'); hash.map(map); - expect(map.zoom).to.have.been.calledWith(20.0); - }); - - it("centers map at requested coordinates", function () { - location.hash = "?map=20.00/38.87952/-77.02405"; - sinon.spy(map, 'center'); - hash.map(map); - expect(map.center).to.have.been.calledWith([-77.02405, 38.87952]); + expect(map.centerZoom).to.have.been.calledWith([-77.02405,38.87952], 20.0); }); it("binds the map's move event", function () { @@ -66,23 +60,13 @@ describe("hash", function () { d3.select(window).one("hashchange", fn); } - it("zooms map to requested level", function (done) { + it("centerZooms map at requested coordinates", function (done) { onhashchange(function () { - expect(map.zoom).to.have.been.calledWith(20.0); + expect(map.centerZoom).to.have.been.calledWith([-77.02405,38.87952], 20.0); done(); }); - sinon.spy(map, 'zoom'); - location.hash = "#?map=20.00/38.87952/-77.02405"; - }); - - it("centers map at requested coordinates", function (done) { - onhashchange(function () { - expect(map.center).to.have.been.calledWith([-77.02405, 38.87952]); - done(); - }); - - sinon.spy(map, 'center'); + sinon.spy(map, 'centerZoom'); location.hash = "#?map=20.00/38.87952/-77.02405"; }); }); From 3eaf4a46e03fb7a1ca2328169d697daac014d8d0 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 15:12:48 -0800 Subject: [PATCH 18/25] Add CSS classes for relation memberships An entity that is a member of a relation will have the classes `member`, `member-role-`, and `member-type-`. The first use of these classes is to avoid filling multipolygon member areas. --- css/map.css | 4 +++ index.html | 1 + js/id/svg/areas.js | 3 +- js/id/svg/lines.js | 40 ++++++++++++------------ js/id/svg/member_classes.js | 32 +++++++++++++++++++ js/id/svg/multipolygons.js | 3 +- js/id/svg/points.js | 3 +- js/id/svg/vertices.js | 1 + test/index.html | 3 ++ test/index_packaged.html | 2 ++ test/spec/svg/areas.js | 12 ++++++++ test/spec/svg/member_classes.js | 54 +++++++++++++++++++++++++++++++++ 12 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 js/id/svg/member_classes.js create mode 100644 test/spec/svg/member_classes.js diff --git a/css/map.css b/css/map.css index fc54d7499..bf600b18d 100644 --- a/css/map.css +++ b/css/map.css @@ -154,6 +154,10 @@ path.multipolygon { fill-rule: evenodd; } +path.area.member-type-multipolygon { + fill: none; +} + path.area.selected { stroke-width:4 !important; } diff --git a/index.html b/index.html index 64d839d93..16dce5779 100644 --- a/index.html +++ b/index.html @@ -43,6 +43,7 @@ + diff --git a/js/id/svg/areas.js b/js/id/svg/areas.js index e77fe8a4d..5a3c31288 100644 --- a/js/id/svg/areas.js +++ b/js/id/svg/areas.js @@ -52,7 +52,8 @@ iD.svg.Areas = function(projection) { paths .order() .attr('d', lineString) - .call(iD.svg.TagClasses()); + .call(iD.svg.TagClasses()) + .call(iD.svg.MemberClasses(graph)); paths.exit() .remove(); diff --git a/js/id/svg/lines.js b/js/id/svg/lines.js index 167a9e75e..375a88f05 100644 --- a/js/id/svg/lines.js +++ b/js/id/svg/lines.js @@ -33,27 +33,27 @@ iD.svg.Lines = function(projection) { return as - bs; } - function drawPaths(group, lines, filter, classes, lineString) { - var paths = group.selectAll('path') - .filter(filter) - .data(lines, iD.Entity.key); - - paths.enter() - .append('path') - .attr('class', classes); - - paths - .order() - .attr('d', lineString) - .call(iD.svg.TagClasses()); - - paths.exit() - .remove(); - - return paths; - } - return function drawLines(surface, graph, entities, filter) { + function drawPaths(group, lines, filter, classes, lineString) { + var paths = group.selectAll('path') + .filter(filter) + .data(lines, iD.Entity.key); + + paths.enter() + .append('path') + .attr('class', classes); + + paths + .order() + .attr('d', lineString) + .call(iD.svg.TagClasses()) + .call(iD.svg.MemberClasses(graph)); + + paths.exit() + .remove(); + + return paths; + } if (!alength) { var arrow = surface.append('text').text(arrowtext); diff --git a/js/id/svg/member_classes.js b/js/id/svg/member_classes.js new file mode 100644 index 000000000..b675288c3 --- /dev/null +++ b/js/id/svg/member_classes.js @@ -0,0 +1,32 @@ +iD.svg.MemberClasses = function(graph) { + var tagClassRe = /^member-?/; + + return function memberClassesSelection(selection) { + selection.each(function memberClassesEach(d, i) { + var classes, value = this.className; + + if (value.baseVal !== undefined) value = value.baseVal; + + classes = value.trim().split(/\s+/).filter(function(name) { + return name.length && !tagClassRe.test(name); + }).join(' '); + + var relations = graph.parentRelations(d); + + if (relations.length) { + classes += ' member'; + } + + relations.forEach(function (relation) { + classes += ' member-type-' + relation.tags.type; + classes += ' member-role-' + _.find(relation.members, function (member) { return member.id == d.id; }).role; + }); + + classes = classes.trim(); + + if (classes !== value) { + d3.select(this).attr('class', classes); + } + }); + }; +}; diff --git a/js/id/svg/multipolygons.js b/js/id/svg/multipolygons.js index bad07ff41..6330cf685 100644 --- a/js/id/svg/multipolygons.js +++ b/js/id/svg/multipolygons.js @@ -40,7 +40,8 @@ iD.svg.Multipolygons = function(projection) { paths .order() .attr('d', lineString) - .call(iD.svg.TagClasses()); + .call(iD.svg.TagClasses()) + .call(iD.svg.MemberClasses(graph)); paths.exit() .remove(); diff --git a/js/id/svg/points.js b/js/id/svg/points.js index 37c4bbf91..5862a5cb9 100644 --- a/js/id/svg/points.js +++ b/js/id/svg/points.js @@ -45,7 +45,8 @@ iD.svg.Points = function(projection) { .attr('transform', 'translate(-8, -8)'); groups.attr('transform', iD.svg.PointTransform(projection)) - .call(iD.svg.TagClasses()); + .call(iD.svg.TagClasses()) + .call(iD.svg.MemberClasses(graph)); // Selecting the following implicitly // sets the data (point entity) on the element diff --git a/js/id/svg/vertices.js b/js/id/svg/vertices.js index ff775fa3e..ce81dface 100644 --- a/js/id/svg/vertices.js +++ b/js/id/svg/vertices.js @@ -31,6 +31,7 @@ iD.svg.Vertices = function(projection) { groups.attr('transform', iD.svg.PointTransform(projection)) .call(iD.svg.TagClasses()) + .call(iD.svg.MemberClasses(graph)) .classed('shared', function(entity) { return graph.parentWays(entity).length > 1; }); // Selecting the following implicitly diff --git a/test/index.html b/test/index.html index 7f63be2a3..0777751d5 100644 --- a/test/index.html +++ b/test/index.html @@ -45,6 +45,7 @@ + @@ -159,6 +160,8 @@ + + diff --git a/test/index_packaged.html b/test/index_packaged.html index 042a0227e..070a2666b 100644 --- a/test/index_packaged.html +++ b/test/index_packaged.html @@ -64,6 +64,8 @@ + + diff --git a/test/spec/svg/areas.js b/test/spec/svg/areas.js index 112e25d03..4f984540a 100644 --- a/test/spec/svg/areas.js +++ b/test/spec/svg/areas.js @@ -28,6 +28,18 @@ describe("iD.svg.Areas", function () { expect(surface.select('.area')).to.be.classed('tag-building-yes'); }); + it("adds member classes", function () { + var area = iD.Way({tags: {area: 'yes'}}), + relation = iD.Relation({members: [{id: area.id, role: 'outer'}], tags: {type: 'multipolygon'}}), + graph = iD.Graph([area, relation]); + + surface.call(iD.svg.Areas(projection), graph, [area], filter); + + expect(surface.select('.area')).to.be.classed('member'); + expect(surface.select('.area')).to.be.classed('member-role-outer'); + expect(surface.select('.area')).to.be.classed('member-type-multipolygon'); + }); + it("preserves non-area paths", function () { var area = iD.Way({tags: {area: 'yes'}}), graph = iD.Graph([area]); diff --git a/test/spec/svg/member_classes.js b/test/spec/svg/member_classes.js new file mode 100644 index 000000000..651ba2342 --- /dev/null +++ b/test/spec/svg/member_classes.js @@ -0,0 +1,54 @@ +describe("iD.svg.MemberClasses", function () { + var selection; + + beforeEach(function () { + selection = d3.select(document.createElementNS('http://www.w3.org/2000/svg', 'g')); + }); + + it("adds no classes to elements that aren't a member of any relations", function() { + var node = iD.Node(), + graph = iD.Graph([node]); + + selection + .datum(node) + .call(iD.svg.MemberClasses(graph)); + + expect(selection.attr('class')).to.equal(null); + }); + + it("adds tags for member, role, and type", function() { + var node = iD.Node(), + relation = iD.Relation({members: [{id: node.id, role: 'r'}], tags: {type: 't'}}), + graph = iD.Graph([node, relation]); + + selection + .datum(node) + .call(iD.svg.MemberClasses(graph)); + + expect(selection.attr('class')).to.equal('member member-type-t member-role-r'); + }); + + it('removes classes for tags that are no longer present', function() { + var node = iD.Entity(), + graph = iD.Graph([node]); + + selection + .attr('class', 'member member-type-t member-role-r') + .datum(node) + .call(iD.svg.MemberClasses(graph)); + + expect(selection.attr('class')).to.equal(''); + }); + + it("preserves existing non-'member-'-prefixed classes", function() { + var node = iD.Entity(), + graph = iD.Graph([node]); + + selection + .attr('class', 'selected') + .datum(node) + .call(iD.svg.MemberClasses(graph)); + + expect(selection.attr('class')).to.equal('selected'); + }); +}); From 2c6e244fb07b09b65ff64cc8e139cc0223e006bc Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 15:13:01 -0800 Subject: [PATCH 19/25] Add tests for iD.svg.Lines --- test/spec/svg/lines.js | 54 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 test/spec/svg/lines.js diff --git a/test/spec/svg/lines.js b/test/spec/svg/lines.js new file mode 100644 index 000000000..61c3229a9 --- /dev/null +++ b/test/spec/svg/lines.js @@ -0,0 +1,54 @@ +describe("iD.svg.Lines", function () { + var surface, + projection = Object, + filter = d3.functor(true); + + beforeEach(function () { + surface = d3.select(document.createElementNS('http://www.w3.org/2000/svg', 'svg')) + .call(iD.svg.Surface()); + }); + + it("adds way and area classes", function () { + var line = iD.Way(), + graph = iD.Graph([line]); + + surface.call(iD.svg.Lines(projection), graph, [line], filter); + + expect(surface.select('path')).to.be.classed('way'); + expect(surface.select('path')).to.be.classed('line'); + }); + + it("adds tag classes", function () { + var line = iD.Way({tags: {highway: 'residential'}}), + graph = iD.Graph([line]); + + surface.call(iD.svg.Lines(projection), graph, [line], filter); + + expect(surface.select('.line')).to.be.classed('tag-highway'); + expect(surface.select('.line')).to.be.classed('tag-highway-residential'); + }); + + it("adds member classes", function () { + var line = iD.Way(), + relation = iD.Relation({members: [{id: line.id}], tags: {type: 'route'}}), + graph = iD.Graph([line, relation]); + + surface.call(iD.svg.Lines(projection), graph, [line], filter); + + expect(surface.select('.line')).to.be.classed('member'); + expect(surface.select('.line')).to.be.classed('member-type-route'); + }); + + it("preserves non-line paths", function () { + var line = iD.Way(), + graph = iD.Graph([line]); + + surface.select('.layer-fill') + .append('path') + .attr('class', 'other'); + + surface.call(iD.svg.Lines(projection), graph, [line], filter); + + expect(surface.selectAll('.other')[0].length).to.equal(1); + }); +}); From 1c21b2a38144b2857ca39fa9e3776f4406531d40 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Thu, 17 Jan 2013 18:20:24 -0500 Subject: [PATCH 20/25] Fix up bbox extent link --- js/id/ui/contributors.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/id/ui/contributors.js b/js/id/ui/contributors.js index 3dc5a4014..c0036a677 100644 --- a/js/id/ui/contributors.js +++ b/js/id/ui/contributors.js @@ -39,8 +39,8 @@ iD.ui.contributors = function(map) { .attr('href', function() { var ext = map.extent(); return 'http://www.openstreetmap.org/browse/changesets?bbox=' + [ - ext[0][0], ext[1][1], - ext[1][0], ext[0][1]]; + ext[0][0], ext[0][1], + ext[1][0], ext[1][1]]; }) .text(' and ' + (u.length - limit) + ' others'); } From d2921caf6cf64fbeb2152aec4cff895a2cfaea9c Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 15:57:15 -0800 Subject: [PATCH 21/25] Fix build --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 657915e1e..788eef43e 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ all: \ js/lib/d3.keybinding.js \ js/lib/d3.one.js \ js/lib/d3.size.js \ + js/lib/d3.tail.js \ js/lib/d3.trigger.js \ js/lib/d3.typeahead.js \ js/lib/jxon.js \ From e933b9d088799bfe63d38c2cb72cccf3f441d818 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 16:31:45 -0800 Subject: [PATCH 22/25] Include parent relations recursively Fixes drawing when dragging a multipolygon vertex. --- js/id/renderer/map.js | 44 +++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/js/id/renderer/map.js b/js/id/renderer/map.js index 75043a1f8..729d37936 100644 --- a/js/id/renderer/map.js +++ b/js/id/renderer/map.js @@ -70,31 +70,31 @@ iD.Map = function() { filter = d3.functor(true); } else { var only = {}; - for (var j = 0; j < difference.length; j++) { - var id = difference[j], - entity = graph.fetch(id); - // Even if the entity is false (deleted), it needs to be - // removed from the surface - only[id] = entity; - if (entity && entity.intersects(extent, graph)) { - if (only[id].type === 'node') { - var parents = graph.parentWays(only[id]); - for (var k = 0; k < parents.length; k++) { - // Don't re-fetch parents - if (only[parents[k].id] === undefined) { - only[parents[k].id] = graph.fetch(parents[k].id); - } - } - parents = graph.parentRelations(only[id]); - for (k = 0; k < parents.length; k++) { - // Don't re-fetch parents - if (only[parents[k].id] === undefined) { - only[parents[k].id] = parents[k].id; - } - } + + function addParents(parents) { + for (var i = 0; i < parents.length; i++) { + var parent = parents[i]; + if (only[parent.id] === undefined) { + only[parent.id] = graph.fetch(parent.id); + addParents(graph.parentRelations(parent)); } } } + + for (var j = 0; j < difference.length; j++) { + var id = difference[j], + entity = graph.fetch(id); + + // Even if the entity is false (deleted), it needs to be + // removed from the surface + only[id] = entity; + + if (entity && entity.intersects(extent, graph)) { + addParents(graph.parentWays(only[id])); + addParents(graph.parentRelations(only[id])); + } + } + all = _.compact(_.values(only)); filter = function(d) { return d.midpoint ? d.way in only : d.id in only; }; } From d40c6451708d8f60651fddc4a022c19f860459c7 Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 16:32:50 -0800 Subject: [PATCH 23/25] Fix packaged tests --- test/index_packaged.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/index_packaged.html b/test/index_packaged.html index 070a2666b..630ed1088 100644 --- a/test/index_packaged.html +++ b/test/index_packaged.html @@ -20,7 +20,10 @@ From bf251dce150e6376a5768241d99044799fd654fb Mon Sep 17 00:00:00 2001 From: John Firebaugh Date: Thu, 17 Jan 2013 17:20:22 -0800 Subject: [PATCH 24/25] Handle incomplete relations --- js/id/graph/relation.js | 14 ++++++++------ test/spec/graph/relation.js | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/js/id/graph/relation.js b/js/id/graph/relation.js index 490b98ac7..7d8a9aa89 100644 --- a/js/id/graph/relation.js +++ b/js/id/graph/relation.js @@ -4,11 +4,13 @@ iD.Relation = iD.Entity.extend({ extent: function(resolver) { return resolver.transient(this, 'extent', function() { - var extent = iD.geo.Extent(); - for (var i = 0, l = this.members.length; i < l; i++) { - extent = extent.extend(resolver.entity(this.members[i].id).extent(resolver)); - } - return extent; + return this.members.reduce(function (extent, member) { + if (member = resolver.entity(member.id)) { + return extent.extend(member.extent(resolver)) + } else { + return extent; + } + }, iD.geo.Extent()); }); }, @@ -28,7 +30,7 @@ iD.Relation = iD.Entity.extend({ // multipolygon: function(resolver) { var members = this.members - .filter(function (m) { return m.type === 'way'; }) + .filter(function (m) { return m.type === 'way' && resolver.entity(m.id); }) .map(function (m) { return { role: m.role || 'outer', id: m.id, nodes: resolver.fetch(m.id).nodes }; }); function join(ways) { diff --git a/test/spec/graph/relation.js b/test/spec/graph/relation.js index 7395521c0..8880fc98c 100644 --- a/test/spec/graph/relation.js +++ b/test/spec/graph/relation.js @@ -44,6 +44,15 @@ describe('iD.Relation', function () { expect(r.extent(graph)).to.eql([[0, 0], [5, 10]]) }); + + it("returns the known extent of incomplete relations", function () { + var a = iD.Node({loc: [0, 0]}), + b = iD.Node({loc: [5, 10]}), + r = iD.Relation({members: [{id: a.id}, {id: b.id}]}), + graph = iD.Graph([a, r]); + + expect(r.extent(graph)).to.eql([[0, 0], [0, 0]]) + }); }); describe("#multipolygon", function () { @@ -239,5 +248,17 @@ describe('iD.Relation', function () { expect(r.multipolygon(graph)).to.eql([[[a, b, c, a], [d, e, f, d]], [[g, h, i, g]]]); }); + + specify("incomplete relation", function () { + var a = iD.Node(), + b = iD.Node(), + c = iD.Node(), + w1 = iD.Way({nodes: [a.id, b.id, c.id]}), + w2 = iD.Way(), + r = iD.Relation({members: [{id: w2.id, type: 'way'}, {id: w1.id, type: 'way'}]}), + g = iD.Graph([a, b, c, w1, r]); + + expect(r.multipolygon(g)).to.eql([[[a, b, c]]]); + }); }); }); From 0246f2c5dd759b5d8a83fa989ee7885ac7288129 Mon Sep 17 00:00:00 2001 From: saman bb Date: Fri, 18 Jan 2013 01:17:08 -0500 Subject: [PATCH 25/25] style / layout refactoring --- css/app.css | 179 +++--- img/source/sprite.svg | 1155 +++++++++++++------------------------ img/sprite.png | Bin 15999 -> 12910 bytes js/id/id.js | 3 +- js/id/ui/inspector.js | 2 +- js/id/ui/layerswitcher.js | 6 +- 6 files changed, 484 insertions(+), 861 deletions(-) diff --git a/css/app.css b/css/app.css index 53eec9933..8b46887f5 100644 --- a/css/app.css +++ b/css/app.css @@ -18,7 +18,7 @@ body { } .limiter { - max-width: 1400px; + max-width: 1200px; } div, textarea, input, span, ul, li, ol, a, button { @@ -164,24 +164,15 @@ ul li { list-style: none;} ul.toggle-list li a { font-weight: bold; padding: 10px; - border-top: 1px solid #CCC; + border-top: 1px solid white; display:block; } -ul.toggle-list li a:hover { - background: #ececec; -} - ul.toggle-list .icon { float: left; margin-right: 5px; } -a.selected { - color:#222; -} - - ul.link-list li { float: left; display: inline-block; @@ -199,11 +190,10 @@ ul.link-list li:first-child { /* Utility Classes ------------------------------------------------------- */ -.fillL { background-color: white;} +.fillL { background: white;} .fillL2 { background: #f7f7f7 url(../img/background-pattern-1.png) repeat;} .fillD { - background-color: #222222; - background-color: rgba(0,0,0,.8); + background: rgba(0,0,0,.7); color: white; } @@ -216,9 +206,8 @@ form.hide { } .content { - background-color:#fff; + background:#fff; border-radius: 4px; - border: 1px solid #ccc; } .pad1 {padding: 10px;} @@ -230,8 +219,7 @@ form.hide { button { line-height:20px; - border:1px solid #aaa; - box-shadow: inset 0 0 0px 1px #fff; + border:0; color:#222; background: white; font-weight:bold; @@ -242,17 +230,20 @@ button { } button:hover { - background-color: #ececec; + background: #ececec; +} + +button.col3:hover { + background: #bde5aa; } button.active { - box-shadow: inset 0 0 0px 1px #fff, inset 0 0 6px 1px rgba(0,0,0,.35); cursor:url(../img/cursor-pointing.png) 6 1, auto; } button.active:not([disabled]) { - background-color: #ececec; - box-shadow: inset 0 0 0px 1px #fff, inset 0 0 6px 1px rgba(0,0,0,.25); + background: #6bc641; + color: white; } button.wide, @@ -282,14 +273,15 @@ button.centered { .button-wrap { display: inline-block; - padding:10px 0 10px 10px; + padding-right:10px; margin: 0; } .button-wrap button:only-child { width: 100%;} +.button-wrap:last-child { padding-right: 0; } .joined button { - border-right-width: 0; + border-right: 1px solid rgba(0,0,0,.5); border-radius:0; } @@ -298,15 +290,16 @@ button.centered { } .joined button:last-child { - border-right-width: 1px; + border-right-width: 0px; border-radius:0 4px 4px 0; } button.action { - background: #444; + color: white; + background: #7092ff; } button.action:hover { - background: #222; + background: #597BE7; } button.delete { @@ -328,15 +321,9 @@ button.save .count { } button.save.has-count .count { - display: block; - color: #444; - background: #fff; - border-radius: 0 3px 3px 0; - padding: 9px; - float: right; - margin-left: 10px; - margin-top: -9px; - margin-right: -8px; + display: inline-block; + color: rgba(255,255,255,.5); + padding-left: 5px; } button.close { @@ -346,22 +333,22 @@ button.close { } button .label { - margin-right: 3px; + display: none; } -button.action .label { +button.save .label { + display: inline-block; color: white; - text-shadow: 0 -1px 0 rgba(0,0,0,.25); } button[disabled] { cursor:auto; - background: white; + background: #cecece; pointer-events:none; } button[disabled] .label { - color:#ccc; + color:#999999; text-shadow: none; } @@ -386,18 +373,13 @@ button[disabled]:hover { height: 40px; } -.icon.icon-pre-text { - margin-right: 3px; -} - /* Definitions for every icon */ - -.icon.browse { background-position: 0px 0px;} -.icon.add-point { background-position: -20px 0px;} -.icon.add-line { background-position: -40px 0px;} -.icon.add-area { background-position: -60px 0px;} -.icon.undo { background-position: -80px 0px;} -.icon.redo { background-position: -100px 0px;} +button.active:not([disabled]) .icon.browse { background-position: 0px -20px;} +button.active:not([disabled]) .icon.add-point { background-position: -20px -20px;} +button.active:not([disabled]) .icon.add-line { background-position: -40px -20px;} +button.active:not([disabled]) .icon.add-area { background-position: -60px -20px;} +button.active:not([disabled]) .icon.undo { background-position: -80px -20px;} +button.active:not([disabled]) .icon.redo { background-position: -100px -20px;} .icon.apply { background-position: -120px 0px;} .icon.save { background-position: -140px 0px;} .icon.close { background-position: -160px 0px;} @@ -411,10 +393,13 @@ button[disabled]:hover { .icon.avatar { background-position: -320px 0px;} .icon.nearby { background-position: -340px 0px;} -.fillD .icon.browse { background-position: 0px -20px;} -.fillD .icon.add-point { background-position: -20px -20px;} -.fillD .icon.add-line { background-position: -40px -20px;} -.fillD .icon.add-area { background-position: -60px -20px;} +.icon.browse { background-position: 0px 0px;} +.icon.add-point { background-position: -20px 0px;} +.icon.add-line { background-position: -40px 0px;} +.icon.add-area { background-position: -60px 0px;} +.icon.undo { background-position: -80px 0px;} +.icon.redo { background-position: -100px 0px;} + .fillD .icon.undo { background-position: -80px -20px;} .fillD .icon.redo { background-position: -100px -20px;} .fillD .icon.apply { background-position: -120px -20px;} @@ -422,11 +407,11 @@ button[disabled]:hover { .fillD .icon.close { background-position: -160px -20px;} .fillD .icon.delete { background-position: -180px -20px;} .fillD .icon.remove { background-position: -200px -20px;} -.fillD .icon.inspect { background-position: -220px -20px;} -.fillD .icon.zoom-in { background-position: -240px -20px;} -.fillD .icon.zoom-out { background-position: -260px -20px;} -.fillD .icon.geocode { background-position: -280px -20px;} -.fillD .icon.layers { background-position: -300px -20px;} +.map-control .icon.inspect { background-position: -220px -20px;} +.map-control .icon.zoom-in { background-position: -240px -20px;} +.map-control .icon.zoom-out { background-position: -260px -20px;} +.map-control .icon.geocode { background-position: -280px -20px;} +.map-control .icon.layers { background-position: -300px -20px;} .fillD .icon.avatar { background-position: -320px -20px;} .fillD .icon.nearby { background-position: -340px -20px;} @@ -465,12 +450,12 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} ------------------------------------------------------- */ #bar { - border-bottom:1px solid #ccc; position:absolute; left:0px; top:0px; right:0; height:60px; + background: rgba(0,0,0,.8); } /* Status box */ @@ -485,12 +470,10 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} opacity:0; display:none; padding-left: 10px; - max-width: 600px; + max-width: 500px; } .inspector { - border-left: 1px solid #ccc; - border-bottom: 1px solid #ccc; min-height: 60px; position: relative; } @@ -500,7 +483,6 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} } .inspector-inner.head { - border-bottom: 1px solid #ccc; background:#fff; z-index:1; position:relative; @@ -580,13 +562,14 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} right: 30px; } -.inspector-buttons { - border-top: 1px solid #ccc; -} - .inspector-buttons .button-wrap { width: 20%; } +.inspector-buttons .button-wrap:first-child { padding-right: 5px;} + +.inspector-buttons .button-wrap:last-child { + padding-left: 5px; +} .inspector-inner .add-tag-row { width: 100%; @@ -604,12 +587,22 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} /* Map Controls */ .map-control { - left:10px; + left:0px; position:absolute; } .map-control button { width: 40px; + border-radius: 0 4px 4px 0; + background: rgba(0, 0, 0, .8); +} + +.map-control button:hover { + background: rgba(0, 0, 0, .9); +} + +.map-control button.active:hover { + background: #6bc641; } .map-overlay { @@ -618,6 +611,7 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} left:50px; top:0; display: block; + border-radius: 4px; } /* Zoomer */ @@ -628,12 +622,13 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} } .zoombuttons button.zoom-in { - border-radius:4px 4px 0 0; + border-radius:0 4px 0 0; + border-bottom: 1px solid rgba(0, 0, 0, .5); } .zoombuttons button.zoom-out { border-top:0; - border-radius:0 0 4px 4px; + border-radius:0 0 4px 0; } /* Layer Switcher */ @@ -642,28 +637,24 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} top:210px; } -.layerswitcher-control .adjustments { - padding:5px; - opacity:0.2; -} - -.layerswitcher-control .adjustments:hover { - opacity:1; -} - -.layerswitcher-control .adjustments .reset { +.layerswitcher-control .adjustments button { + opacity:0.5; height:20px; font-size:10px; font-weight:normal; - padding:0 5px; + padding:0 5px 3px 5px; + background: white; + border: 1px solid #ddd; + border-radius: 0; +} + +.layerswitcher-control .adjustments button:hover { + opacity: 1; } .layerswitcher-control .nudge { - height:20px; width:20px; - font-size:10px; margin-right:2px; - font-weight:normal; } .opacity-options-wrapper { @@ -735,7 +726,7 @@ a.selected:hover .toggle.icon { background-position: -40px -180px;} display:block; position:absolute; overflow:hidden; - top:60px; + top:0px; left:0; right:0; bottom:0; @@ -860,9 +851,6 @@ div.typeahead a:first-child { text-align: center; } -.modal button { margin-bottom: 0;} -.modal button:first-child { margin-left: 0;} - .modal button.close-modal { float:right; margin-right:10px; @@ -912,11 +900,14 @@ div.typeahead a:first-child { } .modal-section { - padding: 20px; - border-bottom: 1px solid #ccc; + padding: 10px; } -.modal-section:last-child { border-bottom: 0;} + +.body .modal-section:last-child { + border-bottom: 0; + border-radius: 0 0 4px 4px; +} .modal-section img.wiki-image { max-width: 400px; diff --git a/img/source/sprite.svg b/img/source/sprite.svg index f239c672f..c3fdcf407 100644 --- a/img/source/sprite.svg +++ b/img/source/sprite.svg @@ -13,7 +13,7 @@ height="200" id="svg12393" version="1.1" - inkscape:version="0.48.2 r9819" + inkscape:version="0.48.1 r9760" sodipodi:docname="sprite.svg" inkscape:export-filename="/Users/saman/work_repos/iD/img/sprite.png" inkscape:export-xdpi="90" @@ -39,8 +39,8 @@ inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:zoom="1" - inkscape:cx="150.66428" - inkscape:cy="90.493266" + inkscape:cx="54.93799" + inkscape:cy="205.49862" inkscape:document-units="px" inkscape:current-layer="layer12" showgrid="false" @@ -165,7 +165,7 @@ image/svg+xml - + @@ -175,110 +175,6 @@ id="layer1" transform="translate(-25,-62.362183)" style="display:inline"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + style="color:#000000;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;marker:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate;opacity:1" /> @@ -935,26 +464,6 @@ inkscape:export-xdpi="90" inkscape:export-ydpi="90" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1260,30 +548,12 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/img/sprite.png b/img/sprite.png index 2f933d9f8a00da49db90ff2fb02c237dac4d9d75..8a536fa47d550a09c705367d8441a5eb2ba1ea2f 100644 GIT binary patch literal 12910 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 Teg1&|8v;C5)q+*4SV#U3d7R4z literal 15999 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 S$pE?#g4C3>kmZP{A^#6j{Kzx_ diff --git a/js/id/id.js b/js/id/id.js index 2edc7fd23..b1beb425f 100644 --- a/js/id/id.js +++ b/js/id/id.js @@ -22,7 +22,8 @@ window.iD = function(container) { .call(map); var bar = container.append('div') - .attr('id', 'bar').attr('class', 'fillL2'); + .attr('id', 'bar') + .attr('class','pad1'); var limiter = bar.append('div') .attr('class', 'limiter'); diff --git a/js/id/ui/inspector.js b/js/id/ui/inspector.js index b1d26a53b..1724e8ed4 100644 --- a/js/id/ui/inspector.js +++ b/js/id/ui/inspector.js @@ -47,7 +47,7 @@ iD.ui.inspector = function() { drawTags(entity.tags); inspectorbody.append('div') - .attr('class', 'inspector-buttons') + .attr('class', 'inspector-buttons pad1') .call(drawButtons); } diff --git a/js/id/ui/layerswitcher.js b/js/id/ui/layerswitcher.js index a2acad1b5..4556fbe19 100644 --- a/js/id/ui/layerswitcher.js +++ b/js/id/ui/layerswitcher.js @@ -56,7 +56,7 @@ iD.ui.layerswitcher = function(map) { var opa = content .append('div') - .attr('class', 'opacity-options-wrapper fillL2'); + .attr('class', 'opacity-options-wrapper'); opa.append('h4').text('Layers'); @@ -102,7 +102,7 @@ iD.ui.layerswitcher = function(map) { content .append('ul') - .attr('class', 'toggle-list') + .attr('class', 'toggle-list fillL2') .selectAll('a.layer') .data(sources) .enter() @@ -135,7 +135,7 @@ iD.ui.layerswitcher = function(map) { var adjustments = content .append('div') - .attr('class', 'adjustments'); + .attr('class', 'adjustments pad1'); var directions = [ ['←', [-1, 0]],