From e11d97b38cb52fddec470bc68a6c6d58f952770b Mon Sep 17 00:00:00 2001 From: SilentSpike Date: Sat, 7 Dec 2019 15:35:51 +0000 Subject: [PATCH] Add Osmose QA layer and service Initial implementation - need to add UI for the errors and correctly set up support for the desired error types provided by osmose. --- css/65_data.css | 10 +- data/core.yaml | 7 +- dist/locales/en.json | 6 +- modules/modes/select_error.js | 13 +- modules/services/index.js | 5 +- modules/services/osmose.js | 238 +++++++++++++++++++++++++++++++ modules/svg/layers.js | 4 +- modules/svg/osmose.js | 261 ++++++++++++++++++++++++++++++++++ modules/ui/commit.js | 8 +- modules/ui/map_data.js | 4 +- test/spec/svg/layers.js | 21 +-- 11 files changed, 556 insertions(+), 21 deletions(-) create mode 100644 modules/services/osmose.js create mode 100644 modules/svg/osmose.js diff --git a/css/65_data.css b/css/65_data.css index 8de9ca09c..837cc9688 100644 --- a/css/65_data.css +++ b/css/65_data.css @@ -3,7 +3,8 @@ .error-header-icon .qa_error-fill, .layer-keepRight .qa_error .qa_error-fill, -.layer-improveOSM .qa_error .qa_error-fill { +.layer-improveOSM .qa_error .qa_error-fill, +.layer-osmose .qa_error .qa_error-fill { stroke: #333; stroke-width: 1.3px; /* NOTE: likely a better way to scale the icon stroke */ } @@ -152,6 +153,11 @@ color: #EC1C24; } +/* Osmose Errors +------------------------------------------------------- */ +.osmose { + color: #FFFFFF; +} /* Custom Map Data (geojson, gpx, kml, vector tile) */ .layer-mapdata { @@ -211,4 +217,4 @@ stroke: #000; stroke-width: 5px; stroke-miterlimit: 1; -} +} \ No newline at end of file diff --git a/data/core.yaml b/data/core.yaml index d68008f9a..e030339e9 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -630,6 +630,9 @@ en: improveOSM: tooltip: Missing data automatically detected by improveosm.org title: ImproveOSM Issues + osmose: + tooltip: Automatically detected map issues from osmose.openstreetmap.fr + title: Osmose Issues custom: tooltip: "Drag and drop a data file onto the page, or click the button to setup" title: Custom Map Data @@ -1343,7 +1346,7 @@ en: title: Quality Assurance intro: "*Quality Assurance* (Q/A) tools can find improper tags, disconnected roads, and other issues with OpenStreetMap, which mappers can then fix. To view existing Q/A issues, click the {data} **Map data** panel to enable a specific Q/A layer." tools_h: "Tools" - tools: "The following tools are currently supported: [KeepRight](https://www.keepright.at/) and [ImproveOSM](https://improveosm.org/en/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/) and more Q/A tools in the future." + tools: "The following tools are currently supported: [KeepRight](https://www.keepright.at/), [ImproveOSM](https://improveosm.org/en/) and [Osmose](https://osmose.openstreetmap.fr/). Expect iD to support more Q/A tools in the future." issues_h: "Handling Issues" issues: "Handling Q/A issues is similar to handling notes. Click on a marker to view the issue details in the sidebar. Each tool has its own capabilities, but generally you can comment and/or close an issue." field: @@ -2058,4 +2061,4 @@ en: wikidata: identifier: "Identifier" label: "Label" - description: "Description" + description: "Description" \ No newline at end of file diff --git a/dist/locales/en.json b/dist/locales/en.json index 1aa7361e5..31a21fdba 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -782,6 +782,10 @@ "tooltip": "Missing data automatically detected by improveosm.org", "title": "ImproveOSM Issues" }, + "osmose": { + "tooltip": "Automatically detected map issues from osmose.openstreetmap.fr", + "title": "Osmose Issues" + }, "custom": { "tooltip": "Drag and drop a data file onto the page, or click the button to setup", "title": "Custom Map Data", @@ -1653,7 +1657,7 @@ "title": "Quality Assurance", "intro": "*Quality Assurance* (Q/A) tools can find improper tags, disconnected roads, and other issues with OpenStreetMap, which mappers can then fix. To view existing Q/A issues, click the {data} **Map data** panel to enable a specific Q/A layer.", "tools_h": "Tools", - "tools": "The following tools are currently supported: [KeepRight](https://www.keepright.at/) and [ImproveOSM](https://improveosm.org/en/). Expect iD to support [Osmose](https://osmose.openstreetmap.fr/) and more Q/A tools in the future.", + "tools": "The following tools are currently supported: [KeepRight](https://www.keepright.at/), [ImproveOSM](https://improveosm.org/en/) and [Osmose](https://osmose.openstreetmap.fr/). Expect iD to support more Q/A tools in the future.", "issues_h": "Handling Issues", "issues": "Handling Q/A issues is similar to handling notes. Click on a marker to view the issue details in the sidebar. Each tool has its own capabilities, but generally you can comment and/or close an issue." }, diff --git a/modules/modes/select_error.js b/modules/modes/select_error.js index 774822b68..0af8b3acb 100644 --- a/modules/modes/select_error.js +++ b/modules/modes/select_error.js @@ -15,6 +15,7 @@ import { modeDragNode } from './drag_node'; import { modeDragNote } from './drag_note'; import { uiImproveOsmEditor } from '../ui/improveOSM_editor'; import { uiKeepRightEditor } from '../ui/keepRight_editor'; +import { uiOsmoseEditor } from '../ui/osmose_editor'; import { utilKeybinding } from '../util'; @@ -49,6 +50,16 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService) .show(errorEditor.error(error)); }); break; + case 'osmose': + errorEditor = uiOsmoseEditor(context) + .on('change', function() { + context.map().pan([0,0]); // trigger a redraw + var error = checkSelectedID(); + if (!error) return; + context.ui().sidebar + .show(errorEditor.error(error)); + }); + break; } @@ -154,4 +165,4 @@ export function modeSelectError(context, selectedErrorID, selectedErrorService) return mode; -} +} \ No newline at end of file diff --git a/modules/services/index.js b/modules/services/index.js index 838ed435f..ab9aa5503 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,5 +1,6 @@ import serviceKeepRight from './keepRight'; import serviceImproveOSM from './improveOSM'; +import serviceOsmose from './osmose'; import serviceMapillary from './mapillary'; import serviceMapRules from './maprules'; import serviceNominatim from './nominatim'; @@ -17,6 +18,7 @@ export var services = { geocoder: serviceNominatim, keepRight: serviceKeepRight, improveOSM: serviceImproveOSM, + osmose: serviceOsmose, mapillary: serviceMapillary, openstreetcam: serviceOpenstreetcam, osm: serviceOsm, @@ -32,6 +34,7 @@ export var services = { export { serviceKeepRight, serviceImproveOSM, + serviceOsmose, serviceMapillary, serviceMapRules, serviceNominatim, @@ -43,4 +46,4 @@ export { serviceVectorTile, serviceWikidata, serviceWikipedia -}; +}; \ No newline at end of file diff --git a/modules/services/osmose.js b/modules/services/osmose.js new file mode 100644 index 000000000..36181b579 --- /dev/null +++ b/modules/services/osmose.js @@ -0,0 +1,238 @@ +import RBush from 'rbush'; + +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { json as d3_json } from 'd3-fetch'; + +import { geoExtent, geoVecAdd, geoVecScale } from '../geo'; +import { qaError } from '../osm'; +import { t } from '../util/locale'; +import { utilRebind, utilTiler, utilQsString } from '../util'; + + +var tiler = utilTiler(); +var dispatch = d3_dispatch('loaded'); + +var _erCache; +var _erZoom = 14; + +var _osmoseUrlRoot = 'https://osmose.openstreetmap.fr/en/api/0.3beta/'; + +function abortRequest(controller) { + if (controller) { + controller.abort(); + } +} + +function abortUnwantedRequests(cache, tiles) { + Object.keys(cache.inflightTile).forEach(function(k) { + var wanted = tiles.find(function(tile) { return k === tile.id; }); + if (!wanted) { + abortRequest(cache.inflightTile[k]); + delete cache.inflightTile[k]; + } + }); +} + + +function encodeErrorRtree(d) { + return { minX: d.loc[0], minY: d.loc[1], maxX: d.loc[0], maxY: d.loc[1], data: d }; +} + + +// replace or remove error from rtree +function updateRtree(item, replace) { + _erCache.rtree.remove(item, function isEql(a, b) { + return a.data.id === b.data.id; + }); + + if (replace) { + _erCache.rtree.insert(item); + } +} + +function linkErrorObject(d) { + return '' + d + ''; +} + +function linkEntity(d) { + return '' + d + ''; +} + +// Errors shouldn't obscure eachother +function preventCoincident(loc, bumpUp) { + var coincident = false; + do { + // first time, move marker up. after that, move marker right. + var delta = coincident ? [0.00001, 0] : (bumpUp ? [0, 0.00001] : [0, 0]); + loc = geoVecAdd(loc, delta); + var bbox = geoExtent(loc).bbox(); + coincident = _erCache.rtree.search(bbox).length; + } while (coincident); + + return loc; +} + +export default { + init: function() { + if (!_erCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function() { + if (_erCache) { + Object.values(_erCache.inflightTile).forEach(abortRequest); + } + _erCache = { + data: {}, + loadedTile: {}, + inflightTile: {}, + inflightPost: {}, + closed: {}, + rtree: new RBush() + }; + }, + + loadErrors: function(projection) { + var options = { + full: 'true', // Returns element IDs + level: '1,2,3', + zoom: '19' + }; + + // determine the needed tiles to cover the view + var tiles = tiler + .zoomExtent([_erZoom, _erZoom]) + .getTiles(projection); + + // abort inflight requests that are no longer needed + abortUnwantedRequests(_erCache, tiles); + + // issue new requests.. + tiles.forEach(function(tile) { + if (_erCache.loadedTile[tile.id] || _erCache.inflightTile[tile.id]) return; + + var rect = tile.extent.rectangle(); // E, N, W, S + var params = Object.assign({}, options, { bbox: [rect[0], rect[1], rect[2], rect[3]].join() }); + + var url = _osmoseUrlRoot + 'issues?' + utilQsString(params); + + var controller = new AbortController(); + _erCache.inflightTile[tile.id] = controller; + + d3_json(url, { signal: controller.signal }) + .then(function(data) { + delete _erCache.inflightTile[tile.id]; + _erCache.loadedTile[tile.id] = true; + + if (data.issues) { + data.issues.forEach(function(issue) { + // Elements provided as string, separated by _ character + var elems = issue.elems.split('_'); + var loc = [issue.lon, issue.lat]; + + loc = preventCoincident(loc, true); + + var d = new qaError({ + // Info required for every error + loc: loc, + service: 'osmose', + error_type: [issue.item, issue.classs].join('-'), + // Extra details needed for this service + identifier: issue.id, // this is used to post changes to the error + elems: elems + //object_id: elems[0], + //object_type: elems[0].substring(0,1) + }); + + // Variables used in the description + d.replacements = { + }; + + _erCache.data[d.id] = d; + _erCache.rtree.insert(encodeErrorRtree(d)); + }); + } + }) + .catch(function() { + delete _erCache.inflightTile[tile.id]; + _erCache.loadedTile[tile.id] = true; + }); + }); + }, + + postUpdate: function(d, callback) { + if (_erCache.inflightPost[d.id]) { + return callback({ message: 'Error update already inflight', status: -2 }, d); + } + + var that = this; + + if (err) { return callback(err, d); } + + // UI sets the status to either '/done' or '/false' + var url = _osmoseUrlRoot + 'issue/' + d.identifier + d.newStatus; + + var controller = new AbortController(); + _erCache.inflightPost[d.id] = controller; + + fetch(url, { method: 'POST', signal: controller.signal }) + .then(function() { + delete _erCache.inflightPost[d.id]; + + that.removeError(d); + if (d.newStatus === '/done') { + // No pretty identifier, so we just use coordinates + var closedID = d.loc[1].toFixed(5) + '/' + d.loc[0].toFixed(5); + _erCache.closed[key + ':' + closedID] = true; + } + if (callback) callback(null, d); + }) + .catch(function(err) { + delete _erCache.inflightPost[d.id]; + if (callback) callback(err.message); + }); + }, + + + // get all cached errors covering the viewport + getErrors: function(projection) { + var viewport = projection.clipExtent(); + var min = [viewport[0][0], viewport[1][1]]; + var max = [viewport[1][0], viewport[0][1]]; + var bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox(); + + return _erCache.rtree.search(bbox).map(function(d) { + return d.data; + }); + }, + + // get a single error from the cache + getError: function(id) { + return _erCache.data[id]; + }, + + // replace a single error in the cache + replaceError: function(error) { + if (!(error instanceof qaError) || !error.id) return; + + _erCache.data[error.id] = error; + updateRtree(encodeErrorRtree(error), true); // true = replace + return error; + }, + + // remove a single error from the cache + removeError: function(error) { + if (!(error instanceof qaError) || !error.id) return; + + delete _erCache.data[error.id]; + updateRtree(encodeErrorRtree(error), false); // false = remove + }, + + // Used to populate `closed:osmose` changeset tag + getClosedIDs: function() { + return Object.keys(_erCache.closed).sort(); + } +}; \ No newline at end of file diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 12595a7d7..d0c46df53 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -6,6 +6,7 @@ import { svgDebug } from './debug'; import { svgGeolocate } from './geolocate'; import { svgKeepRight } from './keepRight'; import { svgImproveOSM } from './improveOSM'; +import { svgOsmose } from './osmose'; import { svgStreetside } from './streetside'; import { svgMapillaryImages } from './mapillary_images'; import { svgMapillarySigns } from './mapillary_signs'; @@ -27,6 +28,7 @@ export function svgLayers(projection, context) { { id: 'data', layer: svgData(projection, context, dispatch) }, { id: 'keepRight', layer: svgKeepRight(projection, context, dispatch) }, { id: 'improveOSM', layer: svgImproveOSM(projection, context, dispatch) }, + { id: 'osmose', layer: svgOsmose(projection, context, dispatch) }, { id: 'streetside', layer: svgStreetside(projection, context, dispatch)}, { id: 'mapillary', layer: svgMapillaryImages(projection, context, dispatch) }, { id: 'mapillary-map-features', layer: svgMapillaryMapFeatures(projection, context, dispatch) }, @@ -116,4 +118,4 @@ export function svgLayers(projection, context) { return utilRebind(drawLayers, dispatch, 'on'); -} +} \ No newline at end of file diff --git a/modules/svg/osmose.js b/modules/svg/osmose.js new file mode 100644 index 000000000..1126087d6 --- /dev/null +++ b/modules/svg/osmose.js @@ -0,0 +1,261 @@ +import _throttle from 'lodash-es/throttle'; +import { select as d3_select } from 'd3-selection'; + +import { modeBrowse } from '../modes/browse'; +import { svgPointTransform } from './helpers'; +import { services } from '../services'; + +var _osmoseEnabled = false; +var _errorService; + + +export function svgOsmose(projection, context, dispatch) { + var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + var minZoom = 12; + var touchLayer = d3_select(null); + var drawLayer = d3_select(null); + var _osmoseVisible = false; + + function markerPath(selection, klass) { + selection + .attr('class', klass) + .attr('transform', 'translate(-10, -28)') + .attr('points', '16,3 4,3 1,6 1,17 4,20 7,20 10,27 13,20 16,20 19,17.033 19,6'); + } + + + // Loosely-coupled osmose service for fetching errors. + function getService() { + if (services.osmose && !_errorService) { + _errorService = services.osmose; + _errorService.on('loaded', throttledRedraw); + } else if (!services.osmose && _errorService) { + _errorService = null; + } + + return _errorService; + } + + + // Show the errors + function editOn() { + if (!_osmoseVisible) { + _osmoseVisible = true; + drawLayer + .style('display', 'block'); + } + } + + + // Immediately remove the errors and their touch targets + function editOff() { + if (_osmoseVisible) { + _osmoseVisible = false; + drawLayer + .style('display', 'none'); + drawLayer.selectAll('.qa_error.osmose') + .remove(); + touchLayer.selectAll('.qa_error.osmose') + .remove(); + } + } + + + // Enable the layer. This shows the errors and transitions them to visible. + function layerOn() { + editOn(); + + drawLayer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end interrupt', function () { + dispatch.call('change'); + }); + } + + + // Disable the layer. This transitions the layer invisible and then hides the errors. + function layerOff() { + throttledRedraw.cancel(); + drawLayer.interrupt(); + touchLayer.selectAll('.qa_error.osmose') + .remove(); + + drawLayer + .transition() + .duration(250) + .style('opacity', 0) + .on('end interrupt', function () { + editOff(); + dispatch.call('change'); + }); + } + + + // Update the error markers + function updateMarkers() { + if (!_osmoseVisible || !_osmoseEnabled) return; + + var service = getService(); + var selectedID = context.selectedErrorID(); + var data = (service ? service.getErrors(projection) : []); + var getTransform = svgPointTransform(projection); + + // Draw markers.. + var markers = drawLayer.selectAll('.qa_error.osmose') + .data(data, function(d) { return d.id; }); + + // exit + markers.exit() + .remove(); + + // enter + var markersEnter = markers.enter() + .append('g') + .attr('class', function(d) { + return [ + 'qa_error', + d.service, + 'error_id-' + d.id, + 'error_type-' + d.error_type, + 'category-' + d.category + ].join(' '); + }); + + markersEnter + .append('polygon') + .call(markerPath, 'shadow'); + + markersEnter + .append('ellipse') + .attr('cx', 0) + .attr('cy', 0) + .attr('rx', 4.5) + .attr('ry', 2) + .attr('class', 'stroke'); + + markersEnter + .append('polygon') + .attr('fill', 'currentColor') + .call(markerPath, 'qa_error-fill'); + + markersEnter + .append('use') + .attr('transform', 'translate(-5.5, -21)') + .attr('class', 'icon-annotation') + .attr('width', '11px') + .attr('height', '11px') + .attr('xlink:href', function(d) { + var picon = d.icon; + + if (!picon) { + return ''; + } else { + var isMaki = /^maki-/.test(picon); + return '#' + picon + (isMaki ? '-11' : ''); + } + }); + + // update + markers + .merge(markersEnter) + .sort(sortY) + .classed('selected', function(d) { return d.id === selectedID; }) + .attr('transform', getTransform); + + + // Draw targets.. + if (touchLayer.empty()) return; + var fillClass = context.getDebug('target') ? 'pink ' : 'nocolor '; + + var targets = touchLayer.selectAll('.qa_error.osmose') + .data(data, function(d) { return d.id; }); + + // exit + targets.exit() + .remove(); + + // enter/update + targets.enter() + .append('rect') + .attr('width', '20px') + .attr('height', '30px') + .attr('x', '-10px') + .attr('y', '-28px') + .merge(targets) + .sort(sortY) + .attr('class', function(d) { + return 'qa_error ' + d.service + ' target error_id-' + d.id + ' ' + fillClass; + }) + .attr('transform', getTransform); + + + function sortY(a, b) { + return (a.id === selectedID) ? 1 + : (b.id === selectedID) ? -1 + : b.loc[1] - a.loc[1]; + } + } + + + // Draw the Osmose layer and schedule loading errors and updating markers. + function drawOsmose(selection) { + var service = getService(); + + var surface = context.surface(); + if (surface && !surface.empty()) { + touchLayer = surface.selectAll('.data-layer.touch .layer-touch.markers'); + } + + drawLayer = selection.selectAll('.layer-osmose') + .data(service ? [0] : []); + + drawLayer.exit() + .remove(); + + drawLayer = drawLayer.enter() + .append('g') + .attr('class', 'layer-osmose') + .style('display', _osmoseEnabled ? 'block' : 'none') + .merge(drawLayer); + + if (_osmoseEnabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadErrors(projection); + updateMarkers(); + } else { + editOff(); + } + } + } + + + // Toggles the layer on and off + drawOsmose.enabled = function(val) { + if (!arguments.length) return _osmoseEnabled; + + _osmoseEnabled = val; + if (_osmoseEnabled) { + layerOn(); + } else { + layerOff(); + if (context.selectedErrorID()) { + context.enter(modeBrowse(context)); + } + } + + dispatch.call('change'); + return this; + }; + + + drawOsmose.supported = function() { + return !!getService(); + }; + + + return drawOsmose; +} \ No newline at end of file diff --git a/modules/ui/commit.js b/modules/ui/commit.js index 3713705df..6f5e293d8 100644 --- a/modules/ui/commit.js +++ b/modules/ui/commit.js @@ -149,6 +149,12 @@ export function uiCommit(context) { tags['closed:improveosm'] = iOsmClosed.join(';').substr(0, tagCharLimit); } } + if (services.osmose) { + var osmoseClosed = services.osmose.getClosedIDs(); + if (osmoseClosed.length) { + tags['closed:osmose'] = osmoseClosed.join(';').substr(0, 255); + } + } // remove existing issue counts for (var key in tags) { @@ -585,4 +591,4 @@ export function uiCommit(context) { return utilRebind(commit, dispatch, 'on'); -} +} \ No newline at end of file diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 2f1fa57bd..d6362a518 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -341,7 +341,7 @@ export function uiMapData(context) { function drawQAItems(selection) { - var qaKeys = ['keepRight', 'improveOSM']; + var qaKeys = ['keepRight', 'improveOSM', 'osmose']; var qaLayers = layers.all().filter(function(obj) { return qaKeys.indexOf(obj.id) !== -1; }); var ul = selection @@ -916,4 +916,4 @@ export function uiMapData(context) { }; return uiMapData; -} +} \ No newline at end of file diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index 09b2b85ce..4a24eaacb 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,20 +26,21 @@ describe('iD.svgLayers', function () { it('creates default data layers', function () { container.call(iD.svgLayers(projection, context)); var nodes = container.selectAll('svg .data-layer').nodes(); - expect(nodes.length).to.eql(13); + expect(nodes.length).to.eql(14); expect(d3.select(nodes[0]).classed('osm')).to.be.true; expect(d3.select(nodes[1]).classed('notes')).to.be.true; expect(d3.select(nodes[2]).classed('data')).to.be.true; expect(d3.select(nodes[3]).classed('keepRight')).to.be.true; expect(d3.select(nodes[4]).classed('improveOSM')).to.be.true; - expect(d3.select(nodes[5]).classed('streetside')).to.be.true; - expect(d3.select(nodes[6]).classed('mapillary')).to.be.true; - expect(d3.select(nodes[7]).classed('mapillary-map-features')).to.be.true; - expect(d3.select(nodes[8]).classed('mapillary-signs')).to.be.true; - expect(d3.select(nodes[9]).classed('openstreetcam')).to.be.true; - expect(d3.select(nodes[10]).classed('debug')).to.be.true; - expect(d3.select(nodes[11]).classed('geolocate')).to.be.true; - expect(d3.select(nodes[12]).classed('touch')).to.be.true; + expect(d3.select(nodes[5]).classed('osmose')).to.be.true; + expect(d3.select(nodes[6]).classed('streetside')).to.be.true; + expect(d3.select(nodes[7]).classed('mapillary')).to.be.true; + expect(d3.select(nodes[8]).classed('mapillary-map-features')).to.be.true; + expect(d3.select(nodes[9]).classed('mapillary-signs')).to.be.true; + expect(d3.select(nodes[10]).classed('openstreetcam')).to.be.true; + expect(d3.select(nodes[11]).classed('debug')).to.be.true; + expect(d3.select(nodes[12]).classed('geolocate')).to.be.true; + expect(d3.select(nodes[13]).classed('touch')).to.be.true; }); -}); +}); \ No newline at end of file