iD: A New Editor for OpenStreetMap

iD: A New Editor for OpenStreetMap

Considers the motivation for building a new editor for OSM, and the design and technology involved in building it. Presented at the Bay Area GeoMeetup April 2, 2013.

A82409021ddf415a8027872b889c5f74?s=128

John Firebaugh

April 02, 2013
Tweet

Transcript

  1. A New Editor for OpenStreetMap John Firebaugh / @jfire

  2. None
  3. None
  4. None
  5. None
  6. None
  7. None
  8. None
  9. None
  10. Motivation

  11. Methods

  12. design usability 37 111

  13. Modality

  14. None
  15. None
  16. Point Line Area

  17. Presets

  18. None
  19. None
  20. None
  21. Details

  22. Drag to fix connectivity

  23. Address autocompletion

  24. High-level design determines whether your app is familiar or foreign.

  25. Low-level details determine whether your app is delightful or tolerable.

  26. Technology

  27. None
  28. None
  29. None
  30. None
  31. d3

  32. d3.geo.mercator

  33. d3.xhr d3.dispatch d3.geo.path d3.behavior.zoom d3.geom.polygon d3.transition

  34. None
  35. None
  36. None
  37. Node Way Relation Graph

  38. Node Way Relation Graph History Current Graph

  39. Node Way Relation Graph Node Way Relation Graph 2 History

    Current Graph Undo History edit tags Action
  40. Node Way Relation Graph Node Way Relation Graph 2 Node

    Way Relation Graph Node History Current Graph Undo History Undo History add node Action edit tags Action
  41. Node Way Relation Graph Node Way Relation Graph 2 Node

    Way Relation Graph Node History Current Graph Redo History Undo History add node Action edit tags Action
  42. Splitting a Way

  43. Splitting a Way

  44. Splitting a Way

  45. Splitting a Way from

  46. Splitting a Way from via

  47. Splitting a Way from via to

  48. Splitting a Way from via

  49. Splitting a Way from via to

  50. // Split a way at the given node. // //

    Optionally, split only the given ways, if multiple ways share // the given node. // // This is the inverse of `iD.actions.Join`. // // For testing convenience, accepts an ID to assign to the new way. // Normally, this will be undefined and the way will automatically // be assigned a new ID. // // Reference: // https://github.com/systemed/potlatch2/blob/master/net/systemeD/halcyon/connection/actions/SplitWayAction.as // iD.actions.Split = function(nodeId, wayIds, newWayIds) { function split(graph, wayA, newWayId) { var wayB = iD.Way({id: newWayId, tags: wayA.tags}), nodesA, nodesB, isArea = wayA.isArea(); if (wayA.isClosed()) { var nodes = wayA.nodes.slice(0, -1), idxA = _.indexOf(nodes, nodeId), idxB = idxA + Math.floor(nodes.length / 2); if (idxB >= nodes.length) { idxB %= nodes.length; nodesA = nodes.slice(idxA).concat(nodes.slice(0, idxB + 1)); nodesB = nodes.slice(idxB, idxA + 1); } else { nodesA = nodes.slice(idxA, idxB + 1); nodesB = nodes.slice(idxB).concat(nodes.slice(0, idxA + 1)); } } else { var idx = _.indexOf(wayA.nodes, nodeId, 1); nodesA = wayA.nodes.slice(0, idx + 1); nodesB = wayA.nodes.slice(idx); } wayA = wayA.update({nodes: nodesA}); wayB = wayB.update({nodes: nodesB}); graph = graph.replace(wayA); graph = graph.replace(wayB); graph.parentRelations(wayA).forEach(function(relation) { if (relation.isRestriction()) { var via = relation.memberByRole('via'); if (via && wayB.contains(via.id)) { relation = relation.updateMember({id: wayB.id}, relation.memberById(wayA.id).index); graph = graph.replace(relation); } } else { var role = relation.memberById(wayA.id).role, last = wayB.last(), i = relation.memberById(wayA.id).index, j; for (j = 0; j < relation.members.length; j++) { var entity = graph.entity(relation.members[j].id); if (entity && entity.type === 'way' && entity.contains(last)) { break; } } relation = relation.addMember({id: wayB.id, type: 'way', role: role}, i <= j ? i + 1 : i); graph = graph.replace(relation); } }); if (isArea) { var multipolygon = iD.Relation({ tags: _.extend({}, wayA.tags, {type: 'multipolygon'}), members: [ {id: wayA.id, role: 'outer', type: 'way'}, {id: wayB.id, role: 'outer', type: 'way'} ]}); graph = graph.replace(multipolygon); graph = graph.replace(wayA.update({tags: {}})); graph = graph.replace(wayB.update({tags: {}})); } return graph; } var action = function(graph) { var candidates = action.ways(graph); for (var i = 0; i < candidates.length; i++) { graph = split(graph, candidates[i], newWayIds && newWayIds[i]); } return graph; }; action.ways = function(graph) { var node = graph.entity(nodeId), parents = graph.parentWays(node); return parents.filter(function(parent) { if (wayIds && wayIds.length && wayIds.indexOf(parent.id) === -1) return false; if (parent.isClosed()) { return true; } for (var i = 1; i < parent.nodes.length - 1; i++) { if (parent.nodes[i] === nodeId) { return true; } } return false; }); }; action.disabled = function(graph) { var candidates = action.ways(graph); if (candidates.length === 0 || (wayIds && wayIds.length && wayIds.length !== candidates.length)) return 'not_eligible'; }; return action; }; describe("iD.actions.Split", function () { describe("#disabled", function () { it("returns falsy for a non-end node of a single way", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}) }); expect(iD.actions.Split('b').disabled(graph)).not.to.be.ok; }); it("returns falsy for an intersection of two ways", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '*': iD.Node({id: '*'}), '-': iD.Way({id: '-', nodes: ['a', '*', 'b']}), '|': iD.Way({id: '|', nodes: ['c', '*', 'd']}) }); expect(iD.actions.Split('*').disabled(graph)).not.to.be.ok; }); it("returns falsy for an intersection of two ways with parent way specified", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '*': iD.Node({id: '*'}), '-': iD.Way({id: '-', nodes: ['a', '*', 'b']}), '|': iD.Way({id: '|', nodes: ['c', '*', 'd']}) }); expect(iD.actions.Split('*', ['-']).disabled(graph)).not.to.be.ok; }); it("returns falsy for a self-intersection", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'a', 'd']}) }); expect(iD.actions.Split('a').disabled(graph)).not.to.be.ok; }); it("returns 'not_eligible' for the first node of a single way", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), '-': iD.Way({id: '-', nodes: ['a', 'b']}) }); expect(iD.actions.Split('a').disabled(graph)).to.equal('not_eligible'); }); it("returns 'not_eligible' for the last node of a single way", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), '-': iD.Way({id: '-', nodes: ['a', 'b']}) }); expect(iD.actions.Split('b').disabled(graph)).to.equal('not_eligible'); }); it("returns 'not_eligible' for an intersection of two ways with non-parent way specified", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '*': iD.Node({id: '*'}), '-': iD.Way({id: '-', nodes: ['a', '*', 'b']}), '|': iD.Way({id: '|', nodes: ['c', '*', 'd']}) }); expect(iD.actions.Split('*', ['-', '=']).disabled(graph)).to.equal('not_eligible'); }); }); it("creates a new way with the appropriate nodes", function () { // Situation: // a ---- b ---- c // // Split at b. // // Expected result: // a ---- b ==== c // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(graph.entity('-').nodes).to.eql(['a', 'b']); expect(graph.entity('=').nodes).to.eql(['b', 'c']); }); it("copies tags to the new way", function () { var tags = {highway: 'residential'}, graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c'], tags: tags}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); // Immutable tags => should be shared by identity. expect(graph.entity('-').tags).to.equal(tags); expect(graph.entity('=').tags).to.equal(tags); }); it("splits a way at a T-junction", function () { // Situation: // a ---- b ---- c // | // d // // Split at b. // // Expected result: // a ---- b ==== c // | // d // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), '|': iD.Way({id: '|', nodes: ['d', 'b']}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(graph.entity('-').nodes).to.eql(['a', 'b']); expect(graph.entity('=').nodes).to.eql(['b', 'c']); expect(graph.entity('|').nodes).to.eql(['d', 'b']); }); it("splits multiple ways at an intersection", function () { // Situation: // c // | // a ---- * ---- b // ¦ // d // // Split at b. // // Expected result: // c // | // a ---- * ==== b // ¦ // d // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '*': iD.Node({id: '*'}), '-': iD.Way({id: '-', nodes: ['a', '*', 'b']}), '|': iD.Way({id: '|', nodes: ['c', '*', 'd']}) }); graph = iD.actions.Split('*', undefined, ['=', '¦'])(graph); expect(graph.entity('-').nodes).to.eql(['a', '*']); expect(graph.entity('=').nodes).to.eql(['*', 'b']); expect(graph.entity('|').nodes).to.eql(['c', '*']); expect(graph.entity('¦').nodes).to.eql(['*', 'd']); }); it("splits the specified ways at an intersection", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '*': iD.Node({id: '*'}), '-': iD.Way({id: '-', nodes: ['a', '*', 'b']}), '|': iD.Way({id: '|', nodes: ['c', '*', 'd']}) }); var g1 = iD.actions.Split('*', ['-'], ['='])(graph); expect(g1.entity('-').nodes).to.eql(['a', '*']); expect(g1.entity('=').nodes).to.eql(['*', 'b']); expect(g1.entity('|').nodes).to.eql(['c', '*', 'd']); var g2 = iD.actions.Split('*', ['|'], ['¦'])(graph); expect(g2.entity('-').nodes).to.eql(['a', '*', 'b']); expect(g2.entity('|').nodes).to.eql(['c', '*']); expect(g2.entity('¦').nodes).to.eql(['*', 'd']); var g3 = iD.actions.Split('*', ['-', '|'], ['=', '¦'])(graph); expect(g3.entity('-').nodes).to.eql(['a', '*']); expect(g3.entity('=').nodes).to.eql(['*', 'b']); expect(g3.entity('|').nodes).to.eql(['c', '*']); expect(g3.entity('¦').nodes).to.eql(['*', 'd']); }); it("splits self-intersecting ways", function () { // Situation: // b // / | // / | // c - a -- d // // Split at a. // // Expected result: // b // / | // / | // c - a == d // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'a', 'd']}) }); graph = iD.actions.Split('a', undefined, ['='])(graph); expect(graph.entity('-').nodes).to.eql(['a', 'b', 'c', 'a']); expect(graph.entity('=').nodes).to.eql(['a', 'd']); }); it("splits a closed way at the given point and its antipode", function () { // Situation: // a ---- b // | | // d ---- c // // Split at a. // // Expected result: // a ---- b // || | // d ==== c // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c', 'd', 'a']}) }); var g1 = iD.actions.Split('a', undefined, ['='])(graph); expect(g1.entity('-').nodes).to.eql(['a', 'b', 'c']); expect(g1.entity('=').nodes).to.eql(['c', 'd', 'a']); var g2 = iD.actions.Split('b', undefined, ['='])(graph); expect(g2.entity('-').nodes).to.eql(['b', 'c', 'd']); expect(g2.entity('=').nodes).to.eql(['d', 'a', 'b']); var g3 = iD.actions.Split('c', undefined, ['='])(graph); expect(g3.entity('-').nodes).to.eql(['c', 'd', 'a']); expect(g3.entity('=').nodes).to.eql(['a', 'b', 'c']); var g4 = iD.actions.Split('d', undefined, ['='])(graph); expect(g4.entity('-').nodes).to.eql(['d', 'a', 'b']); expect(g4.entity('=').nodes).to.eql(['b', 'c', 'd']); }); it("splits an area by converting it to a multipolygon", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', tags: {building: 'yes'}, nodes: ['a', 'b', 'c', 'd', 'a']}) }); graph = iD.actions.Split('a', undefined, ['='])(graph); expect(graph.entity('-').tags).to.eql({}); expect(graph.entity('=').tags).to.eql({}); expect(graph.parentRelations(graph.entity('-'))).to.have.length(1); var relation = graph.parentRelations(graph.entity('-'))[0]; expect(relation.tags).to.eql({type: 'multipolygon', building: 'yes'}); expect(relation.members).to.eql([ {id: '-', role: 'outer', type: 'way'}, {id: '=', role: 'outer', type: 'way'} ]); }); it("adds the new way to parent relations (no connections)", function () { // Situation: // a ---- b ---- c // Relation: [----] // // Split at b. // // Expected result: // a ---- b ==== c // Relation: [----, ====] // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), 'r': iD.Relation({id: 'r', members: [{id: '-', type: 'way', role: 'forward'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(graph.entity('r').members).to.eql([ {id: '-', type: 'way', role: 'forward'}, {id: '=', type: 'way', role: 'forward'} ]); }); it("adds the new way to parent relations (forward order)", function () { // Situation: // a ---- b ---- c ~~~~ d // Relation: [----, ~~~~] // // Split at b. // // Expected result: // a ---- b ==== c ~~~~ d // Relation: [----, ====, ~~~~] // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), '~': iD.Way({id: '~', nodes: ['c', 'd']}), 'r': iD.Relation({id: 'r', members: [{id: '-', type: 'way'}, {id: '~', type: 'way'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(_.pluck(graph.entity('r').members, 'id')).to.eql(['-', '=', '~']); }); it("adds the new way to parent relations (reverse order)", function () { // Situation: // a ---- b ---- c ~~~~ d // Relation: [~~~~, ----] // // Split at b. // // Expected result: // a ---- b ==== c ~~~~ d // Relation: [~~~~, ====, ----] // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), '~': iD.Way({id: '~', nodes: ['c', 'd']}), 'r': iD.Relation({id: 'r', members: [{id: '~', type: 'way'}, {id: '-', type: 'way'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(_.pluck(graph.entity('r').members, 'id')).to.eql(['~', '=', '-']); }); it("handles incomplete relations", function () { var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), 'r': iD.Relation({id: 'r', members: [{id: '~', type: 'way'}, {id: '-', type: 'way'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(_.pluck(graph.entity('r').members, 'id')).to.eql(['~', '-', '=']); }); ['restriction', 'restriction:bus'].forEach(function (type) { it("updates a restriction's 'from' role", function () { // Situation: // a ----> b ----> c ~~~~ d // A restriction from ---- to ~~~~ via c. // // Split at b. // // Expected result: // a ----> b ====> c ~~~~ d // A restriction from ==== to ~~~~ via c. // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), '~': iD.Way({id: '~', nodes: ['c', 'd']}), 'r': iD.Relation({id: 'r', tags: {type: type}, members: [ {id: '-', role: 'from'}, {id: '~', role: 'to'}, {id: 'c', role: 'via'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(graph.entity('r').members).to.eql([ {id: '=', role: 'from'}, {id: '~', role: 'to'}, {id: 'c', role: 'via'}]); }); it("updates a restriction's 'to' role", function () { // Situation: // a ----> b ----> c ~~~~ d // A restriction from ~~~~ to ---- via c. // // Split at b. // // Expected result: // a ----> b ====> c ~~~~ d // A restriction from ~~~~ to ==== via c. // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['a', 'b', 'c']}), '~': iD.Way({id: '~', nodes: ['c', 'd']}), 'r': iD.Relation({id: 'r', tags: {type: type}, members: [ {id: '~', role: 'from'}, {id: '-', role: 'to'}, {id: 'c', role: 'via'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(graph.entity('r').members).to.eql([ {id: '~', role: 'from'}, {id: '=', role: 'to'}, {id: 'c', role: 'via'}]); }); it("leaves unaffected restrictions unchanged", function () { // Situation: // a <---- b <---- c ~~~~ d // A restriction from ---- to ~~~~ via c. // // Split at b. // // Expected result: // a <==== b <---- c ~~~~ d // A restriction from ---- to ~~~~ via c. // var graph = iD.Graph({ 'a': iD.Node({id: 'a'}), 'b': iD.Node({id: 'b'}), 'c': iD.Node({id: 'c'}), 'd': iD.Node({id: 'd'}), '-': iD.Way({id: '-', nodes: ['c', 'b', 'a']}), '~': iD.Way({id: '~', nodes: ['c', 'd']}), 'r': iD.Relation({id: 'r', tags: {type: type}, members: [ {id: '-', role: 'from'}, {id: '~', role: 'to'}, {id: 'c', role: 'via'}]}) }); graph = iD.actions.Split('b', undefined, ['='])(graph); expect(graph.entity('r').members).to.eql([ {id: '-', role: 'from'}, {id: '~', role: 'to'}, {id: 'c', role: 'via'}]); }); }); }); Code Test
  51. osm-auth editor-imagery-index OSM Lab osm-live-map osm-gpx https://github.com/osmlab

  52. Choose libraries based on the needs of the project, not

    popularity.
  53. Good libraries are extracted from working projects, not built in

    isolation.
  54. None
  55. ideditor.com