diff --git a/css/20_map.css b/css/20_map.css index 98674d62e..3e3dab771 100644 --- a/css/20_map.css +++ b/css/20_map.css @@ -318,3 +318,32 @@ text.gpxlabel-halo { stroke-miterlimit: 1; } +/* MVT Paths */ + +.layer-mvt { + pointer-events: none; +} + +path.mvt { + stroke: #ff26d4; + stroke-width: 2; + fill: none; +} + +text.mvtlabel-halo, +text.mvtlabel { + font-size: 10px; + font-weight: bold; + dominant-baseline: middle; +} + +text.mvtlabel { + fill: #ff26d4; +} + +text.mvtlabel-halo { + opacity: 0.7; + stroke: #000; + stroke-width: 5px; + stroke-miterlimit: 1; +} diff --git a/css/80_app.css b/css/80_app.css index 46eaa1da2..6474d9a29 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -2521,6 +2521,10 @@ div.full-screen > button:hover { transform: rotateY(180deg); } +[dir='rtl'] .list-item-mvt-browse svg { + transform: rotateY(180deg); +} + /* make sure tooltip fits in map-control panel */ /* if too wide, placement will be wrong the first time it displays */ .layer-list li.best .tooltip-inner { diff --git a/data/core.yaml b/data/core.yaml index 5b544040b..240c4f12e 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -585,10 +585,15 @@ en: cannot_zoom: "Cannot zoom out further in current mode." full_screen: Toggle Full Screen gpx: - local_layer: "Local file" + local_layer: "Add a GPX" drag_drop: "Drag and drop a .gpx, .geojson or .kml file on the page, or click the button to the right to browse" zoom: "Zoom to layer" browse: "Browse for a file" + mvt: + local_layer: "Add a MVT" + drag_drop: "Drag and drop a .mvt or .pbf file on the page, or click the button to the right to browse" + zoom: "Zoom to layer" + browse: "Browse for a file" streetside: tooltip: "Streetside photos from Microsoft" title: "Photo Overlay (Bing Streetside)" diff --git a/dist/locales/en.json b/dist/locales/en.json index c2c487aa8..bbb7ffd65 100644 --- a/dist/locales/en.json +++ b/dist/locales/en.json @@ -710,7 +710,7 @@ "cannot_zoom": "Cannot zoom out further in current mode.", "full_screen": "Toggle Full Screen", "gpx": { - "local_layer": "Local file", + "local_layer": "Add a GPX", "drag_drop": "Drag and drop a .gpx, .geojson or .kml file on the page, or click the button to the right to browse", "zoom": "Zoom to layer", "browse": "Browse for a file" @@ -721,6 +721,12 @@ "report": "Report a privacy concern with this image", "hires": "High resolution" }, + "mvt": { + "local_layer": "Add a MVT", + "drag_drop": "Drag and drop a .mvt or .pbf file on the page, or click the button to the right to browse", + "zoom": "Zoom to layer", + "browse": "Browse for a file" + }, "mapillary_images": { "tooltip": "Street-level photos from Mapillary", "title": "Photo Overlay (Mapillary)" diff --git a/modules/renderer/background.js b/modules/renderer/background.js index fa2fe6c46..644986cad 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -181,6 +181,14 @@ export function rendererBackground(context) { imageryUsed.push('Bing Streetside'); } + var mvt = context.layers().layer('mvt'); + if (mvt && mvt.enabled() && mvt.hasMvt()) { + // Include a string like '.mvt data file' or '.geojson data file' + var matchmvt = mvt.getSrc().match(/(pbf|mvt|(?:geo)?json)$/i); + var extensionmvt = matchmvt ? ('.' + matchmvt[0].toLowerCase() + ' ') : ''; + imageryUsed.push(extensionmvt + 'data file'); + } + var mapillary_images = context.layers().layer('mapillary-images'); if (mapillary_images && mapillary_images.enabled()) { imageryUsed.push('Mapillary Images'); @@ -429,6 +437,13 @@ export function rendererBackground(context) { } } + if (q.mvt) { + var mvt = context.layers().layer('mvt'); + if (mvt) { + mvt.url(q.mvt); + } + } + if (q.offset) { var offset = q.offset.replace(/;/g, ',').split(',').map(function(n) { return !isNaN(n) && n; diff --git a/modules/svg/index.js b/modules/svg/index.js index 41e9f2b34..bdf081816 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -2,6 +2,7 @@ export { svgAreas } from './areas.js'; export { svgDebug } from './debug.js'; export { svgDefs } from './defs.js'; export { svgGpx } from './gpx.js'; +export { svgMvt } from './mvt.js'; export { svgIcon } from './icon.js'; export { svgLabels } from './labels.js'; export { svgLayers } from './layers.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 3f2ff4816..b86581f9a 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -1,3 +1,4 @@ + import _difference from 'lodash-es/difference'; import _find from 'lodash-es/find'; import _map from 'lodash-es/map'; @@ -9,6 +10,7 @@ import { select as d3_select } from 'd3-selection'; import { svgDebug } from './debug'; import { svgGpx } from './gpx'; import { svgStreetside } from './streetside'; +import { svgMvt } from './mvt'; import { svgMapillaryImages } from './mapillary_images'; import { svgMapillarySigns } from './mapillary_signs'; import { svgOpenstreetcamImages } from './openstreetcam_images'; @@ -23,6 +25,7 @@ export function svgLayers(projection, context) { var layers = [ { id: 'osm', layer: svgOsm(projection, context, dispatch) }, { id: 'gpx', layer: svgGpx(projection, context, dispatch) }, + { id: 'mvt', layer: svgMvt(projection, context, dispatch) }, { id: 'streetside', layer: svgStreetside(projection, context, dispatch)}, { id: 'mapillary-images', layer: svgMapillaryImages(projection, context, dispatch) }, { id: 'mapillary-signs', layer: svgMapillarySigns(projection, context, dispatch) }, diff --git a/modules/svg/mvt.js b/modules/svg/mvt.js new file mode 100644 index 000000000..934927fcf --- /dev/null +++ b/modules/svg/mvt.js @@ -0,0 +1,294 @@ +import _flatten from 'lodash-es/flatten'; +import _isEmpty from 'lodash-es/isEmpty'; +import _isUndefined from 'lodash-es/isUndefined'; +import _reduce from 'lodash-es/reduce'; +import _union from 'lodash-es/union'; + +import { geoBounds as d3_geoBounds } from 'd3-geo'; +import { text as d3_text } from 'd3-request'; +import { buffer } from 'd3-fetch'; +import { + event as d3_event, + select as d3_select +} from 'd3-selection'; + +import { geoExtent, geoPolygonIntersectsPolygon } from '../geo'; +import { svgPath } from './index'; +import { utilDetect } from '../util/detect'; +import vt from '@mapbox/vector-tile'; +import Protobuf from 'pbf'; + + +var _initialized = false; +var _enabled = false; +var _geojson; + + +export function svgMvt(projection, context, dispatch) { + var _showLabels = true; + var detected = utilDetect(); + var layer; + var _src; + + + function init() { + if (_initialized) return; // run once + + _geojson = {}; + _enabled = true; + + function over() { + d3_event.stopPropagation(); + d3_event.preventDefault(); + d3_event.dataTransfer.dropEffect = 'copy'; + } + + d3_select('body') + .attr('dropzone', 'copy') + .on('drop.localmvt', function() { + d3_event.stopPropagation(); + d3_event.preventDefault(); + if (!detected.filedrop) return; + drawMvt.files(d3_event.dataTransfer.files); + }) + .on('dragenter.localmvt', over) + .on('dragexit.localmvt', over) + .on('dragover.localmvt', over); + + _initialized = true; + } + + + function drawMvt(selection) { + var getPath = svgPath(projection).geojson; + + layer = selection.selectAll('.layer-mvt') + .data(_enabled ? [0] : []); + + layer.exit() + .remove(); + + layer = layer.enter() + .append('g') + .attr('class', 'layer-mvt') + .merge(layer); + + + var paths = layer + .selectAll('path') + .data([_geojson]); + + paths.exit() + .remove(); + + paths = paths.enter() + .append('path') + .attr('class', 'mvt') + .merge(paths); + + paths + .attr('d', getPath); + + + var labelData = _showLabels && _geojson.features ? _geojson.features : []; + labelData = labelData.filter(getPath); + + layer + .call(drawLabels, 'mvtlabel-halo', labelData) + .call(drawLabels, 'mvtlabel', labelData); + + + function drawLabels(selection, textClass, data) { + var labels = selection.selectAll('text.' + textClass) + .data(data); + + // exit + labels.exit() + .remove(); + + // enter/update + labels = labels.enter() + .append('text') + .attr('class', textClass) + .merge(labels) + .text(function(d) { + if (d.properties) { + return d.properties.desc || d.properties.name; + } + return null; + }) + .attr('x', function(d) { + var centroid = getPath.centroid(d); + return centroid[0] + 11; + }) + .attr('y', function(d) { + var centroid = getPath.centroid(d); + return centroid[1]; + }); + } + } + + + function vtToGeoJson(bufferdata) { + var tile = new vt.VectorTile(new Protobuf(bufferdata.data)); + var layers = Object.keys(tile.layers); + if (!Array.isArray(layers)) + layers = [layers]; + + var collection = {type: 'FeatureCollection', features: []}; + + layers.forEach(function (layerID) { + var layer = tile.layers[layerID]; + if (layer) { + for (var i = 0; i < layer.length; i++) { + var feature = layer.feature(i).toGeoJSON(bufferdata.zxy[2], bufferdata.zxy[3], bufferdata.zxy[1]); + if (layers.length > 1) feature.properties.vt_layer = layerID; + collection.features.push(feature); + } + } + }); + return collection; + } + + + function getExtension(fileName) { + if (_isUndefined(fileName)) { + return ''; + } + + var lastDotIndex = fileName.lastIndexOf('.'); + if (lastDotIndex < 0) { + return ''; + } + + return fileName.substr(lastDotIndex); + } + + + function parseSaveAndZoom(extension, bufferdata) { + switch (extension) { + default: + drawMvt.geojson(JSON.parse(bufferdata.data)).fitZoom(); + break; + case '.pbf': + drawMvt.geojson(vtToGeoJson(bufferdata)).fitZoom(); + break; + case '.mvt': + drawMvt.geojson(vtToGeoJson(bufferdata)).fitZoom(); + break; + } + } + + + drawMvt.showLabels = function(_) { + if (!arguments.length) return _showLabels; + _showLabels = _; + return this; + }; + + + drawMvt.enabled = function(_) { + if (!arguments.length) return _enabled; + _enabled = _; + dispatch.call('change'); + return this; + }; + + + drawMvt.hasMvt = function() { + return (!(_isEmpty(_geojson) || _isEmpty(_geojson.features))); + }; + + + drawMvt.geojson = function(gj) { + if (!arguments.length) return _geojson; + if (_isEmpty(gj) || _isEmpty(gj.features)) return this; + _geojson = gj; + dispatch.call('change'); + return this; + }; + + drawMvt.url = function(url) { + buffer(url).then(function(data) { + _src = url; + var match = url.match(/(pbf|mvt|(?:geo)?json)/i); + var extension = match ? ('.' + match[0].toLowerCase()) : ''; + var zxy = url.match(/\/(\d+)\/(\d+)\/(\d+)/); + var bufferdata = { + data : data, + zxy : zxy + }; + parseSaveAndZoom(extension, bufferdata); + }); + return this; + }; + + + drawMvt.files = function(fileList) { + if (!fileList.length) return this; + var f = fileList[0], + reader = new FileReader(); + + reader.onload = (function(file) { + _src = file.name; + var extension = getExtension(file.name); + var bufferdata = { + data, + zxy //to-do find x,y,z + }; + return function (e) { + bufferdata.data = e.target.result; + parseSaveAndZoom(extension, bufferdata); + }; + })(f); + + reader.readAsArrayBuffer(f); + return this; + }; + + + drawMvt.getSrc = function () { + return _src; + }; + + + drawMvt.fitZoom = function() { + if (!this.hasMvt()) return this; + + var map = context.map(); + var viewport = map.trimmedExtent().polygon(); + var coords = _reduce(_geojson.features, function(coords, feature) { + var c = feature.geometry.coordinates; + + /* eslint-disable no-fallthrough */ + switch (feature.geometry.type) { + case 'Point': + c = [c]; + case 'MultiPoint': + case 'LineString': + break; + + case 'MultiPolygon': + c = _flatten(c); + case 'Polygon': + case 'MultiLineString': + c = _flatten(c); + break; + } + /* eslint-enable no-fallthrough */ + + return _union(coords, c); + }, []); + + if (!geoPolygonIntersectsPolygon(viewport, coords, true)) { + var extent = geoExtent(d3_geoBounds({ type: 'LineString', coordinates: coords })); + map.centerZoom(extent.center(), map.trimmedExtentZoom(extent)); + } + + return this; + }; + + + init(); + return drawMvt; +} diff --git a/modules/ui/map_data.js b/modules/ui/map_data.js index 32e1eac19..5b6258bae 100644 --- a/modules/ui/map_data.js +++ b/modules/ui/map_data.js @@ -277,6 +277,87 @@ export function uiMapData(context) { .property('checked', showsGpx); } + function drawMvtItem(selection) { + var mvt = layers.layer('mvt'), + hasMvt = mvt && mvt.hasMvt(), + showsMvt = hasMvt && mvt.enabled(); + + var ul = selection + .selectAll('.layer-list-mvt') + .data(mvt ? [0] : []); + + // Exit + ul.exit() + .remove(); + + // Enter + var ulEnter = ul.enter() + .append('ul') + .attr('class', 'layer-list layer-list-mvt'); + + var liEnter = ulEnter + .append('li') + .attr('class', 'list-item-mvt'); + + liEnter + .append('button') + .attr('class', 'list-item-mvt-extent') + .call(tooltip() + .title(t('mvt.zoom')) + .placement((textDirection === 'rtl') ? 'right' : 'left') + ) + .on('click', function() { + d3_event.preventDefault(); + d3_event.stopPropagation(); + mvt.fitZoom(); + }) + .call(svgIcon('#icon-search')); + + liEnter + .append('button') + .attr('class', 'list-item-mvt-browse') + .call(tooltip() + .title(t('mvt.browse')) + .placement((textDirection === 'rtl') ? 'right' : 'left') + ) + .on('click', function() { + d3_select(document.createElement('input')) + .attr('type', 'file') + .on('change', function() { + mvt.files(d3_event.target.files); + }) + .node().click(); + }) + .call(svgIcon('#icon-geolocate')); + + var labelEnter = liEnter + .append('label') + .call(tooltip() + .title(t('mvt.drag_drop')) + .placement('top') + ); + + labelEnter + .append('input') + .attr('type', 'checkbox') + .on('change', function() { toggleLayer('mvt'); }); + + labelEnter + .append('span') + .text(t('mvt.local_layer')); + + // Update + ul = ul + .merge(ulEnter); + + ul.selectAll('.list-item-mvt') + .classed('active', showsMvt) + .selectAll('label') + .classed('deemphasize', !hasMvt) + .selectAll('input') + .property('disabled', !hasMvt) + .property('checked', showsMvt); + } function drawListItems(selection, data, type, name, change, active) { var items = selection.selectAll('li') @@ -369,7 +450,8 @@ export function uiMapData(context) { _dataLayerContainer .call(drawOsmItem) .call(drawPhotoItems) - .call(drawGpxItem); + .call(drawGpxItem) + .call(drawMvtItem); _fillList .call(drawListItems, fills, 'radio', 'area_fill', setFill, showsFill); diff --git a/modules/util/util.js b/modules/util/util.js index 5c93b5c9c..e747a7262 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -101,6 +101,9 @@ export function utilStringQs(str) { if (parts.length === 2) { obj[parts[0]] = (null === parts[1]) ? '' : decodeURIComponent(parts[1]); } + if (parts[0] === 'mvt') { + obj[parts[0]] = (parts[2] != undefined) ? (decodeURIComponent(parts[1]) + '=' + decodeURIComponent(parts[2])) : (decodeURIComponent(parts[1])); + } return obj; }, {}); } diff --git a/package.json b/package.json index d7e9e51e9..202980115 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "dependencies": { "@mapbox/sexagesimal": "1.1.0", "@mapbox/togeojson": "0.16.0", + "@mapbox/vector-tile": "^1.3.1", + "d3-fetch": "^1.1.0", "diacritics": "1.3.0", "lodash-es": "4.17.10", "marked": "0.4.0", diff --git a/test/spec/svg/layers.js b/test/spec/svg/layers.js index e01c76d47..5e8b148ad 100644 --- a/test/spec/svg/layers.js +++ b/test/spec/svg/layers.js @@ -26,14 +26,15 @@ 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(7); + expect(nodes.length).to.eql(8); 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-streetside')).to.be.true; - expect(d3.select(nodes[3]).classed('data-layer-mapillary-images')).to.be.true; - expect(d3.select(nodes[4]).classed('data-layer-mapillary-signs')).to.be.true; - expect(d3.select(nodes[5]).classed('data-layer-openstreetcam-images')).to.be.true; - expect(d3.select(nodes[6]).classed('data-layer-debug')).to.be.true; + expect(d3.select(nodes[2]).classed('data-layer-mvt')).to.be.true; + expect(d3.select(nodes[3]).classed('data-layer-streetside')).to.be.true; + expect(d3.select(nodes[4]).classed('data-layer-mapillary-images')).to.be.true; + expect(d3.select(nodes[5]).classed('data-layer-mapillary-signs')).to.be.true; + expect(d3.select(nodes[6]).classed('data-layer-openstreetcam-images')).to.be.true; + expect(d3.select(nodes[7]).classed('data-layer-debug')).to.be.true; }); });