diff --git a/css/60_mapillary.css b/css/60_photos.css similarity index 73% rename from css/60_mapillary.css rename to css/60_photos.css index a3067ea7e..f1c81e420 100644 --- a/css/60_mapillary.css +++ b/css/60_photos.css @@ -67,6 +67,46 @@ } +/* OpenStreetCam Image Layer */ + +.layer-openstreetcam-images { + pointer-events: none; +} + +.layer-openstreetcam-images .viewfield-group { + pointer-events: visible; + cursor: pointer; +} + +.layer-openstreetcam-images .viewfield-group * { + stroke-width: 1; + stroke: #444; + fill: #ffc600; + z-index: 50; +} + +.layer-openstreetcam-images .viewfield-group:hover * { + stroke-width: 1; + stroke: #333; + fill: #ff9900; + z-index: 60; +} + +.layer-openstreetcam-images .viewfield-group.selected * { + stroke-width: 2; + stroke: #222; + fill: #ff5800; + z-index: 60; +} + +.layer-openstreetcam-images .viewfield-group:hover path.viewfield, +.layer-openstreetcam-images .viewfield-group.selected path.viewfield, +.layer-openstreetcam-images .viewfield-group path.viewfield { + stroke-width: 0; + fill-opacity: 0.6; +} + + /* Mapillary viewer */ #mly .domRenderer .TagSymbol { font-size: 10px; diff --git a/data/core.yaml b/data/core.yaml index a55aa2fff..7d41e40ae 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -546,6 +546,11 @@ en: title: "Traffic Sign Overlay (Mapillary)" mapillary: view_on_mapillary: "View this image on Mapillary" + openstreetcam_images: + tooltip: "Street-level photos from OpenStreetCam" + title: "Photo Overlay (OpenStreetCam)" + openstreetcam: + view_on_openstreetcam: "View this image on OpenStreetCam" help: title: "Help" key: H diff --git a/dist/locales/en.json b/dist/locales/en.json index 49cda42a7..4d4b92219 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -670,6 +670,13 @@ "mapillary": { "view_on_mapillary": "View this image on Mapillary" }, + "openstreetcam_images": { + "tooltip": "Street-level photos from OpenStreetCam", + "title": "Photo Overlay (OpenStreetCam)" + }, + "openstreetcam": { + "view_on_openstreetcam": "View this image on OpenStreetCam" + }, "help": { "title": "Help", "key": "H", diff --git a/modules/renderer/background.js b/modules/renderer/background.js index ba6946e3e..a6d72909b 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -104,6 +104,11 @@ export function rendererBackground(context) { imageryUsed.push('Mapillary Signs'); } + var openstreetcam_images = context.layers().layer('openstreetcam-images'); + if (openstreetcam_images && openstreetcam_images.enabled()) { + imageryUsed.push('OpenStreetCam Images'); + } + context.history().imageryUsed(imageryUsed); }; diff --git a/modules/services/index.js b/modules/services/index.js index f893714ab..d5ab2d365 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -1,13 +1,15 @@ import serviceMapillary from './mapillary'; import serviceNominatim from './nominatim'; +import serviceOpenstreetcam from './openstreetcam'; import serviceOsm from './osm'; import serviceTaginfo from './taginfo'; import serviceWikidata from './wikidata'; import serviceWikipedia from './wikipedia'; export var services = { - mapillary: serviceMapillary, geocoder: serviceNominatim, + mapillary: serviceMapillary, + openstreetcam: serviceOpenstreetcam, osm: serviceOsm, taginfo: serviceTaginfo, wikidata: serviceWikidata, diff --git a/modules/services/openstreetcam.js b/modules/services/openstreetcam.js new file mode 100644 index 000000000..eb32ade74 --- /dev/null +++ b/modules/services/openstreetcam.js @@ -0,0 +1,356 @@ +import _filter from 'lodash-es/filter'; +import _find from 'lodash-es/find'; +import _flatten from 'lodash-es/flatten'; +import _forEach from 'lodash-es/forEach'; +import _map from 'lodash-es/map'; +import _some from 'lodash-es/some'; + +import { range as d3_range } from 'd3-array'; +import { dispatch as d3_dispatch } from 'd3-dispatch'; +import { request as d3_request } from 'd3-request'; + +import { + select as d3_select, + selectAll as d3_selectAll +} from 'd3-selection'; + +import rbush from 'rbush'; + +import { d3geoTile as d3_geoTile } from '../lib/d3.geo.tile'; +import { geoExtent } from '../geo'; +import { utilQsString, utilRebind } from '../util'; + + +var apibase = 'http://openstreetcam.org', + maxResults = 1000, + tileZoom = 14, + dispatch = d3_dispatch('loadedImages'), + openstreetcamCache, + openstreetcamImage; + + +function abortRequest(i) { + i.abort(); +} + + +function nearNullIsland(x, y, z) { + if (z >= 7) { + var center = Math.pow(2, z - 1), + width = Math.pow(2, z - 6), + min = center - (width / 2), + max = center + (width / 2) - 1; + return x >= min && x <= max && y >= min && y <= max; + } + return false; +} + + +function maxPageAtZoom(z) { + if (z < 15) return 2; + if (z === 15) return 5; + if (z === 16) return 10; + if (z === 17) return 20; + if (z === 18) return 40; + if (z > 18) return 80; +} + + +function getTiles(projection) { + var s = projection.scale() * 2 * Math.PI, + z = Math.max(Math.log(s) / Math.log(2) - 8, 0), + ts = 256 * Math.pow(2, z - tileZoom), + origin = [ + s / 2 - projection.translate()[0], + s / 2 - projection.translate()[1]]; + + return d3_geoTile() + .scaleExtent([tileZoom, tileZoom]) + .scale(s) + .size(projection.clipExtent()[1]) + .translate(projection.translate())() + .map(function(tile) { + var x = tile[0] * ts - origin[0], + y = tile[1] * ts - origin[1]; + + return { + id: tile.toString(), + xyz: tile, + extent: geoExtent( + projection.invert([x, y + ts]), + projection.invert([x + ts, y]) + ) + }; + }); +} + + +function loadTiles(which, url, projection) { + var s = projection.scale() * 2 * Math.PI, + currZoom = Math.floor(Math.max(Math.log(s) / Math.log(2) - 8, 0)); + + var tiles = getTiles(projection).filter(function(t) { + return !nearNullIsland(t.xyz[0], t.xyz[1], t.xyz[2]); + }); + + _filter(which.inflight, function(v, k) { + var wanted = _find(tiles, function(tile) { return k === (tile.id + ',0'); }); + if (!wanted) delete which.inflight[k]; + return !wanted; + }).map(abortRequest); + + tiles.forEach(function(tile) { + loadNextTilePage(which, currZoom, url, tile); + }); +} + + +function loadNextTilePage(which, currZoom, url, tile) { + var cache = openstreetcamCache[which]; + var bbox = tile.extent.bbox(); + var maxPages = maxPageAtZoom(currZoom); + var nextPage = cache.nextPage[tile.id] || 1; + var params = utilQsString({ + ipp: maxResults, + page: nextPage, + // client_id: clientId, + bbTopLeft: [bbox.maxY, bbox.minX].join(','), + bbBottomRight: [bbox.minY, bbox.maxX].join(',') + }, true); + + if (nextPage > maxPages) return; + + var id = tile.id + ',' + String(nextPage); + if (cache.loaded[id] || cache.inflight[id]) return; + + cache.inflight[id] = d3_request(url) + .mimeType('application/json') + .post(params, function(err, data) { + cache.loaded[id] = true; + delete cache.inflight[id]; + if (err || !data.currentPageItems || !data.currentPageItems.length) return; + + var features = data.currentPageItems.map(function(item) { + var loc = [+item.lng, +item.lat], + d; + + if (which === 'images') { + d = { + loc: loc, + key: item.id, + ca: +item.heading, + captured_at: item.date_added, + }; + } + return { + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + }; + }); + + cache.rtree.load(features); + + if (which === 'images') { + dispatch.call('loadedImages'); + } + + if (data.currentPageItems.length === maxResults) { // more pages to load + cache.nextPage[tile.id] = nextPage + 1; + loadNextTilePage(which, currZoom, url, tile); + } else { + cache.nextPage[tile.id] = Infinity; // no more pages to load + } + }); +} + + +// partition viewport into `psize` x `psize` regions +function partitionViewport(psize, projection) { + var dimensions = projection.clipExtent()[1]; + psize = psize || 16; + var cols = d3_range(0, dimensions[0], psize), + rows = d3_range(0, dimensions[1], psize), + partitions = []; + + rows.forEach(function(y) { + cols.forEach(function(x) { + var min = [x, y + psize], + max = [x + psize, y]; + partitions.push( + geoExtent(projection.invert(min), projection.invert(max))); + }); + }); + + return partitions; +} + + +// no more than `limit` results per partition. +function searchLimited(psize, limit, projection, rtree) { + limit = limit || 3; + + var partitions = partitionViewport(psize, projection); + var results; + + results = _flatten(_map(partitions, function(extent) { + return rtree.search(extent.bbox()) + .slice(0, limit) + .map(function(d) { return d.data; }); + })); + return results; +} + + + +export default { + + init: function() { + if (!openstreetcamCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + reset: function() { + var cache = openstreetcamCache; + + if (cache) { + if (cache.images && cache.images.inflight) { + _forEach(cache.images.inflight, abortRequest); + } + } + + openstreetcamCache = { + images: { inflight: {}, loaded: {}, nextPage: {}, rtree: rbush() } + }; + + openstreetcamImage = null; + }, + + + images: function(projection) { + var psize = 16, limit = 3; + return searchLimited(psize, limit, projection, openstreetcamCache.images.rtree); + }, + + loadImages: function(projection) { + var url = apibase + '/1.0/list/nearby-photos/'; + loadTiles('images', url, projection); + }, + + + loadViewer: function(context) { + // var that = this; + // var wrap = d3_select('#content').selectAll('.openstreetcam-wrap') + // .data([0]); + + // var enter = wrap.enter() + // .append('div') + // .attr('class', 'openstreetcam-wrap') + // .classed('al', true) // 'al'=left, 'ar'=right + // .classed('hidden', true); + + // enter + // .append('button') + // .attr('class', 'thumb-hide') + // .on('click', function () { that.hideViewer(); }) + // .append('div') + // .call(svgIcon('#icon-close')); + + // enter + // .append('div') + // .attr('id', 'mly') + // .attr('class', 'mly-wrapper') + // .classed('active', false); + }, + + + showViewer: function() { + // d3_select('#content') + // .selectAll('.openstreetcam-wrap') + // .classed('hidden', false) + // .selectAll('.mly-wrapper') + // .classed('active', true); + + return this; + }, + + + hideViewer: function() { + // d3_select('#content') + // .selectAll('.openstreetcam-wrap') + // .classed('hidden', true) + // .selectAll('.mly-wrapper') + // .classed('active', false); + + // d3_selectAll('.layer-openstreetcam-images .viewfield-group') + // .classed('selected', false); + + openstreetcamImage = null; + return this; + }, + + + updateViewer: function(imageKey, context) { + if (!imageKey) return; + + // if (!openstreetcamViewer) { + // this.initViewer(imageKey, context); + // } else { + // openstreetcamViewer.moveToKey(imageKey); + // } + + return this; + }, + + + selectedImage: function(imageKey) { + if (!arguments.length) return openstreetcamImage; + openstreetcamImage = imageKey; + + // d3_selectAll('.layer-openstreetcam-images .viewfield-group') + // .classed('selected', function(d) { + // return d.key === imageKey; + // }); + + // if (!imageKey) return this; + + + // function localeTimestamp(s) { + // if (!s) return null; + // var d = new Date(s); + // if (isNaN(d.getTime())) return null; + // return d.toLocaleString(undefined, { timeZone: 'UTC' }); + // } + + // var selected = d3_selectAll('.layer-openstreetcam-images .viewfield-group.selected'); + // if (selected.empty()) return this; + + // var datum = selected.datum(); + // var timestamp = localeTimestamp(datum.captured_at); + // var attribution = d3_select('.openstreetcam-js-dom .Attribution'); + // var capturedAt = attribution.selectAll('.captured-at'); + // if (capturedAt.empty()) { + // attribution + // .append('span') + // .text('|'); + // capturedAt = attribution + // .append('span') + // .attr('class', 'captured-at'); + // } + // capturedAt + // .text(timestamp); + + // this.updateDetections(); + + return this; + }, + + + cache: function(_) { + if (!arguments.length) return openstreetcamCache; + openstreetcamCache = _; + return this; + } + +}; diff --git a/modules/svg/index.js b/modules/svg/index.js index 9b48ba93d..8f54f5a5e 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -10,6 +10,7 @@ export { svgMapillaryImages } from './mapillary_images.js'; export { svgMapillarySigns } from './mapillary_signs.js'; export { svgMidpoints } from './midpoints.js'; export { svgOneWaySegments } from './one_way_segments.js'; +export { svgOpenstreetcamImages } from './openstreetcam_images.js'; export { svgOsm } from './osm.js'; export { svgPath } from './path.js'; export { svgPointTransform } from './point_transform.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index b0f590410..08f92db18 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -10,6 +10,7 @@ import { svgDebug } from './debug'; import { svgGpx } from './gpx'; import { svgMapillaryImages } from './mapillary_images'; import { svgMapillarySigns } from './mapillary_signs'; +import { svgOpenstreetcamImages } from './openstreetcam_images'; import { svgOsm } from './osm'; import { utilRebind } from '../util/rebind'; import { utilGetDimensions, utilSetDimensions } from '../util/dimensions'; @@ -23,6 +24,7 @@ export function svgLayers(projection, context) { { id: 'gpx', layer: svgGpx(projection, context, dispatch) }, { id: 'mapillary-images', layer: svgMapillaryImages(projection, context, dispatch) }, { id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) }, + { id: 'openstreetcam-images', layer: svgOpenstreetcamImages(projection, context, dispatch) }, { id: 'debug', layer: svgDebug(projection, context, dispatch) } ]; diff --git a/modules/svg/openstreetcam_images.js b/modules/svg/openstreetcam_images.js new file mode 100644 index 000000000..5087ee96d --- /dev/null +++ b/modules/svg/openstreetcam_images.js @@ -0,0 +1,189 @@ +import _throttle from 'lodash-es/throttle'; +import { select as d3_select } from 'd3-selection'; +import { svgPointTransform } from './point_transform'; +import { services } from '../services'; + + +export function svgOpenstreetcamImages(projection, context, dispatch) { + var throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000), + minZoom = 12, + minViewfieldZoom = 17, + layer = d3_select(null), + _openstreetcam; + + + function init() { + if (svgOpenstreetcamImages.initialized) return; // run once + svgOpenstreetcamImages.enabled = false; + svgOpenstreetcamImages.initialized = true; + } + + + function getOpenstreetcam() { + if (services.openstreetcam && !_openstreetcam) { + _openstreetcam = services.openstreetcam; + _openstreetcam.event.on('loadedImages', throttledRedraw); + } else if (!services.openstreetcam && _openstreetcam) { + _openstreetcam = null; + } + + return _openstreetcam; + } + + + function showLayer() { + var openstreetcam = getOpenstreetcam(); + if (!openstreetcam) return; + + openstreetcam.loadViewer(context); + editOn(); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', function () { dispatch.call('change'); }); + } + + + function hideLayer() { + var openstreetcam = getOpenstreetcam(); + if (openstreetcam) { + openstreetcam.hideViewer(); + } + + throttledRedraw.cancel(); + + layer + .transition() + .duration(250) + .style('opacity', 0) + .on('end', editOff); + } + + + function editOn() { + layer.style('display', 'block'); + } + + + function editOff() { + layer.selectAll('.viewfield-group').remove(); + layer.style('display', 'none'); + } + + + function click(d) { + var openstreetcam = getOpenstreetcam(); + if (!openstreetcam) return; + + context.map().centerEase(d.loc); + + openstreetcam + .selectedImage(d.key, true) + .updateViewer(d.key, context) + .showViewer(); + } + + + function transform(d) { + var t = svgPointTransform(projection)(d); + if (d.ca) t += ' rotate(' + Math.floor(d.ca) + ',0,0)'; + return t; + } + + + function update() { + var openstreetcam = getOpenstreetcam(), + data = (openstreetcam ? openstreetcam.images(projection) : []), + imageKey = openstreetcam ? openstreetcam.selectedImage() : null; + + var markers = layer.selectAll('.viewfield-group') + .data(data, function(d) { return d.key; }); + + markers.exit() + .remove(); + + var enter = markers.enter() + .append('g') + .attr('class', 'viewfield-group') + .classed('selected', function(d) { return d.key === imageKey; }) + .on('click', click); + + markers = markers + .merge(enter) + .attr('transform', transform); + + + var viewfields = markers.selectAll('.viewfield') + .data(~~context.map().zoom() >= minViewfieldZoom ? [0] : []); + + viewfields.exit() + .remove(); + + viewfields.enter() + .append('path') + .attr('class', 'viewfield') + .attr('transform', 'scale(1.5,1.5),translate(-8, -13)') + .attr('d', 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z'); + + markers.selectAll('circle') + .data([0]) + .enter() + .append('circle') + .attr('dx', '0') + .attr('dy', '0') + .attr('r', '6'); + } + + + function drawImages(selection) { + var enabled = svgOpenstreetcamImages.enabled, + openstreetcam = getOpenstreetcam(); + + layer = selection.selectAll('.layer-openstreetcam-images') + .data(openstreetcam ? [0] : []); + + layer.exit() + .remove(); + + layer = layer.enter() + .append('g') + .attr('class', 'layer-openstreetcam-images') + .style('display', enabled ? 'block' : 'none') + .merge(layer); + + if (enabled) { + if (openstreetcam && ~~context.map().zoom() >= minZoom) { + editOn(); + update(); + openstreetcam.loadImages(projection); + } else { + editOff(); + } + } + } + + + drawImages.enabled = function(_) { + if (!arguments.length) return svgOpenstreetcamImages.enabled; + svgOpenstreetcamImages.enabled = _; + if (svgOpenstreetcamImages.enabled) { + showLayer(); + } else { + hideLayer(); + } + dispatch.call('change'); + return this; + }; + + + drawImages.supported = function() { + return !!getOpenstreetcam(); + }; + + + init(); + return drawImages; +} diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 12e676d0e..49a238ba3 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -80,113 +80,65 @@ export function uiMapData(context) { } - function clickMapillaryImages() { - toggleLayer('mapillary-images'); - if (!showsLayer('mapillary-images')) { - setLayer('mapillary-signs', false); + function drawPhotoItems(selection) { + var photoKeys = ['mapillary-images', 'mapillary-signs', 'openstreetcam-images']; + var photoLayers = layers.all().filter(function(obj) { return photoKeys.indexOf(obj.id) !== -1; }); + var data = photoLayers.filter(function(obj) { return obj.layer.supported(); }); + + function layerSupported(d) { + return d.layer && d.layer.supported(); + } + function layerEnabled(d) { + return layerSupported(d) && d.layer.enabled(); } - } - - function clickMapillarySigns() { - toggleLayer('mapillary-signs'); - } - - - function drawMapillaryItems(selection) { - var mapillaryImages = layers.layer('mapillary-images'), - mapillarySigns = layers.layer('mapillary-signs'), - supportsMapillaryImages = mapillaryImages && mapillaryImages.supported(), - supportsMapillarySigns = mapillarySigns && mapillarySigns.supported(), - showsMapillaryImages = supportsMapillaryImages && mapillaryImages.enabled(), - showsMapillarySigns = supportsMapillarySigns && mapillarySigns.enabled(); - - var mapillaryList = selection - .selectAll('.layer-list-mapillary') + var ul = selection + .selectAll('.layer-list-photos') .data([0]); - mapillaryList = mapillaryList.enter() + ul = ul.enter() .append('ul') - .attr('class', 'layer-list layer-list-mapillary') - .merge(mapillaryList); + .attr('class', 'layer-list layer-list-photos') + .merge(ul); + var li = ul.selectAll('.list-item-photos') + .data(data); - var mapillaryImageLayerItem = mapillaryList - .selectAll('.list-item-mapillary-images') - .data(supportsMapillaryImages ? [0] : []); - - mapillaryImageLayerItem.exit() + li.exit() .remove(); - var enterImages = mapillaryImageLayerItem.enter() + var liEnter = li.enter() .append('li') - .attr('class', 'list-item-mapillary-images'); + .attr('class', function(d) { return 'list-item-photos list-item-' + d.id; }); - var labelImages = enterImages + var labelEnter = liEnter .append('label') - .call(tooltip() - .title(t('mapillary_images.tooltip')) - .placement('top')); + .each(function(d) { + d3_select(this) + .call(tooltip() + .title(t(d.id.replace('-', '_') + '.tooltip')) + .placement('top') + ); + }); - labelImages + labelEnter .append('input') .attr('type', 'checkbox') - .on('change', clickMapillaryImages); + .on('change', function(d) { toggleLayer(d.id); }); - labelImages + labelEnter .append('span') - .text(t('mapillary_images.title')); + .text(function(d) { return t(d.id.replace('-', '_') + '.title'); }); - var mapillarySignLayerItem = mapillaryList - .selectAll('.list-item-mapillary-signs') - .data(supportsMapillarySigns ? [0] : []); + // Update + li = li + .merge(liEnter); - mapillarySignLayerItem.exit() - .remove(); - - var enterSigns = mapillarySignLayerItem.enter() - .append('li') - .attr('class', 'list-item-mapillary-signs'); - - var labelSigns = enterSigns - .append('label') - .call(tooltip() - .title(t('mapillary_signs.tooltip')) - .placement('top')); - - labelSigns - .append('input') - .attr('type', 'checkbox') - .on('change', clickMapillarySigns); - - labelSigns - .append('span') - .text(t('mapillary_signs.title')); - - - // Updates - mapillaryImageLayerItem = mapillaryImageLayerItem - .merge(enterImages); - - mapillaryImageLayerItem - .classed('active', showsMapillaryImages) + li + .classed('active', layerEnabled) .selectAll('input') - .property('checked', showsMapillaryImages); - - - mapillarySignLayerItem = mapillarySignLayerItem - .merge(enterSigns); - - mapillarySignLayerItem - .classed('active', showsMapillarySigns) - .selectAll('input') - .property('disabled', !showsMapillaryImages) - .property('checked', showsMapillarySigns); - - mapillarySignLayerItem - .selectAll('label') - .classed('deemphasize', !showsMapillaryImages); + .property('checked', layerEnabled); } @@ -377,7 +329,7 @@ export function uiMapData(context) { function update() { dataLayerContainer .call(drawOsmItem) - .call(drawMapillaryItems) + .call(drawPhotoItems) .call(drawGpxItem); fillList diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index ec7ca46ed..e3cb29a89 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,12 +26,13 @@ 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(5); + expect(nodes.length).to.eql(6); expect(d3.select(nodes[0]).classed('data-layer-osm')).to.be.true; expect(d3.select(nodes[1]).classed('data-layer-gpx')).to.be.true; expect(d3.select(nodes[2]).classed('data-layer-mapillary-images')).to.be.true; expect(d3.select(nodes[3]).classed('data-layer-mapillary-signs')).to.be.true; - expect(d3.select(nodes[4]).classed('data-layer-debug')).to.be.true; + expect(d3.select(nodes[4]).classed('data-layer-openstreetcam-images')).to.be.true; + expect(d3.select(nodes[5]).classed('data-layer-debug')).to.be.true; }); });