diff --git a/modules/renderer/background.js b/modules/renderer/background.js index e6ee5d051..236dfc517 100644 --- a/modules/renderer/background.js +++ b/modules/renderer/background.js @@ -251,7 +251,8 @@ export function rendererBackground(context) { mapillary: 'Mapillary Images', 'mapillary-map-features': 'Mapillary Map Features', 'mapillary-signs': 'Mapillary Signs', - kartaview: 'KartaView Images' + kartaview: 'KartaView Images', + mapilio: 'Mapilio Images' }; for (let layerID in photoOverlayLayers) { diff --git a/modules/renderer/photos.js b/modules/renderer/photos.js index ee2705b84..227b4b20a 100644 --- a/modules/renderer/photos.js +++ b/modules/renderer/photos.js @@ -7,7 +7,7 @@ import { utilQsString, utilStringQs } from '../util'; export function rendererPhotos(context) { var dispatch = d3_dispatch('change'); - var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview']; + var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio']; var _allPhotoTypes = ['flat', 'panoramic']; var _shownPhotoTypes = _allPhotoTypes.slice(); // shallow copy var _dateFilters = ['fromDate', 'toDate']; diff --git a/modules/services/index.js b/modules/services/index.js index 6b1784da7..108412f4e 100644 --- a/modules/services/index.js +++ b/modules/services/index.js @@ -13,6 +13,7 @@ import serviceTaginfo from './taginfo'; import serviceVectorTile from './vector_tile'; import serviceWikidata from './wikidata'; import serviceWikipedia from './wikipedia'; +import serviceMapilio from './mapilio'; export let services = { @@ -30,7 +31,8 @@ export let services = { taginfo: serviceTaginfo, vectorTile: serviceVectorTile, wikidata: serviceWikidata, - wikipedia: serviceWikipedia + wikipedia: serviceWikipedia, + mapilio: serviceMapilio }; export { @@ -48,5 +50,6 @@ export { serviceTaginfo, serviceVectorTile, serviceWikidata, - serviceWikipedia + serviceWikipedia, + serviceMapilio }; diff --git a/modules/services/mapilio.js b/modules/services/mapilio.js new file mode 100644 index 000000000..40a56c8fd --- /dev/null +++ b/modules/services/mapilio.js @@ -0,0 +1,200 @@ +import { dispatch as d3_dispatch } from 'd3-dispatch'; + +import Protobuf from 'pbf'; +import RBush from 'rbush'; +import { VectorTile } from '@mapbox/vector-tile'; + +import { utilRebind, utilTiler } from '../util'; + +const baseTileUrl = 'https://geo.mapilio.com/geoserver/gwc/service/wmts?REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&LAYER=mapilio:'; +const pointsTileUrl = `${baseTileUrl}points_mapilio_map&STYLE=&TILEMATRIX=EPSG:900913:{z}&TILEMATRIXSET=EPSG:900913&FORMAT=application/vnd.mapbox-vector-tile&TILECOL={x}&TILEROW={y}`; + +const minZoom = 14; +const dispatch = d3_dispatch('change', 'loadedImages', 'loadedSigns', 'loadedMapFeatures', 'bearingChanged', 'imageChanged'); + +let _mlyActiveImage; +let _mlyCache; + + +// Load all data for the specified type from Mapilio vector tiles +function loadTiles(which, url, maxZoom, projection) { + const tiler = utilTiler().zoomExtent([minZoom, maxZoom]).skipNullIsland(true); + const tiles = tiler.getTiles(projection); + + tiles.forEach(function(tile) { + loadTile(which, url, tile); + }); +} + + +// Load all data for the specified type from one vector tile +function loadTile(which, url, tile) { + const cache = _mlyCache.requests; + const tileId = `${tile.id}-${which}`; + if (cache.loaded[tileId] || cache.inflight[tileId]) return; + const controller = new AbortController(); + cache.inflight[tileId] = controller; + const requestUrl = url.replace('{x}', tile.xyz[0]) + .replace('{y}', tile.xyz[1]) + .replace('{z}', tile.xyz[2]); + + fetch(requestUrl, { signal: controller.signal }) + .then(function(response) { + if (!response.ok) { + throw new Error(response.status + ' ' + response.statusText); + } + cache.loaded[tileId] = true; + delete cache.inflight[tileId]; + return response.arrayBuffer(); + }) + .then(function(data) { + if (!data) { + throw new Error('No Data'); + } + + loadTileDataToCache(data, tile, which); + + if (which === 'images') { + dispatch.call('loadedImages'); + } + }) + .catch(function() { + cache.loaded[tileId] = true; + delete cache.inflight[tileId]; + }); +} + + +// Load the data from the vector tile into cache +function loadTileDataToCache(data, tile, which) { + const vectorTile = new VectorTile(new Protobuf(data)); + let features, + cache, + layer, + i, + feature, + loc, + d; + if (vectorTile.layers.hasOwnProperty('points_mapilio_map')) { + features = []; + cache = _mlyCache.images; + layer = vectorTile.layers.points_mapilio_map; + + for (i = 0; i < layer.length; i++) { + feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]); + loc = feature.geometry.coordinates; + d = { + loc: loc, + captured_at: feature.properties.captured_at, + created_at: feature.properties.created_at, + id: feature.properties.id, + sequence_id: feature.properties.sequence_uuid, + }; + cache.forImageId[d.id] = d; + features.push({ + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + }); + } + if (cache.rtree) { + cache.rtree.load(features); + } + } + + if (vectorTile.layers.hasOwnProperty('sequence')) { + features = []; + cache = _mlyCache.sequences; + layer = vectorTile.layers.sequence; + + for (i = 0; i < layer.length; i++) { + feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]); + if (cache.lineString[feature.properties.id]) { + cache.lineString[feature.properties.id].push(feature); + } else { + cache.lineString[feature.properties.id] = [feature]; + } + } + } + + if (vectorTile.layers.hasOwnProperty('point')) { + features = []; + cache = _mlyCache[which]; + layer = vectorTile.layers.point; + + for (i = 0; i < layer.length; i++) { + feature = layer.feature(i).toGeoJSON(tile.xyz[0], tile.xyz[1], tile.xyz[2]); + loc = feature.geometry.coordinates; + + d = { + loc: loc, + id: feature.properties.id, + first_seen_at: feature.properties.first_seen_at, + last_seen_at: feature.properties.last_seen_at, + value: feature.properties.value + }; + features.push({ + minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d + }); + } + if (cache.rtree) { + cache.rtree.load(features); + } + } + +} + + +export default { + // Initialize Mapilio + init: function() { + if (!_mlyCache) { + this.reset(); + } + + this.event = utilRebind(this, dispatch, 'on'); + }, + + // Reset cache and state + reset: function() { + if (_mlyCache) { + Object.values(_mlyCache.requests.inflight).forEach(function(request) { request.abort(); }); + } + + _mlyCache = { + images: { rtree: new RBush(), forImageId: {} }, + sequences: { rtree: new RBush(), lineString: {} }, + requests: { loaded: {}, inflight: {} } + }; + + _mlyActiveImage = null; + }, + + + // Load images in the visible area + loadImages: function(projection) { + loadTiles('images', pointsTileUrl, 14, projection); + }, + + + // Update the currently highlighted sequence and selected bubble. + setStyles: function(context, hovered) { + const hoveredImageId = hovered && hovered.id; + const hoveredSequenceId = hovered && hovered.sequence_id; + const selectedSequenceId = _mlyActiveImage && _mlyActiveImage.sequence_id; + + context.container().selectAll('.layer-mapilio .viewfield-group') + .classed('highlighted', function(d) { return (d.sequence_id === selectedSequenceId) || (d.id === hoveredImageId); }) + .classed('hovered', function(d) { return d.id === hoveredImageId; }); + + context.container().selectAll('.layer-mapilio .sequence') + .classed('highlighted', function(d) { return d.properties.id === hoveredSequenceId; }) + .classed('currentView', function(d) { return d.properties.id === selectedSequenceId; }); + + return this; + }, + + + // Return the current cache + cache: function() { + return _mlyCache; + } +}; diff --git a/modules/svg/index.js b/modules/svg/index.js index e2c94c316..798afe3f7 100644 --- a/modules/svg/index.js +++ b/modules/svg/index.js @@ -27,3 +27,4 @@ export { svgTagPattern } from './tag_pattern.js'; export { svgTouch } from './touch.js'; export { svgTurns } from './turns.js'; export { svgVertices } from './vertices.js'; +export { svgMapilioImages } from './mapilio_images.js'; diff --git a/modules/svg/layers.js b/modules/svg/layers.js index 169805c4c..f36c5104a 100644 --- a/modules/svg/layers.js +++ b/modules/svg/layers.js @@ -13,6 +13,7 @@ import { svgMapillaryPosition } from './mapillary_position'; import { svgMapillarySigns } from './mapillary_signs'; import { svgMapillaryMapFeatures } from './mapillary_map_features'; import { svgKartaviewImages } from './kartaview_images'; +import { svgMapilioImages } from './mapilio_images'; import { svgOsm } from './osm'; import { svgNotes } from './notes'; import { svgTouch } from './touch'; @@ -38,7 +39,8 @@ export function svgLayers(projection, context) { { id: 'kartaview', layer: svgKartaviewImages(projection, context, dispatch) }, { id: 'debug', layer: svgDebug(projection, context, dispatch) }, { id: 'geolocate', layer: svgGeolocate(projection, context, dispatch) }, - { id: 'touch', layer: svgTouch(projection, context, dispatch) } + { id: 'touch', layer: svgTouch(projection, context, dispatch) }, + { id: 'mapilio', layer: svgMapilioImages(projection, context, dispatch) } ]; diff --git a/modules/svg/mapilio_images.js b/modules/svg/mapilio_images.js new file mode 100644 index 000000000..7724ee126 --- /dev/null +++ b/modules/svg/mapilio_images.js @@ -0,0 +1,129 @@ +import _throttle from 'lodash-es/throttle'; + +import { select as d3_select } from 'd3-selection'; +import { services } from '../services'; + + +export function svgMapilioImages(projection, context, dispatch) { + const throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000); + const minZoom = 12; + let layer = d3_select(null); + let _mapilio; + + + function init() { + if (svgMapilioImages.initialized) return; + svgMapilioImages.enabled = false; + svgMapilioImages.initialized = true; + } + + + function getService() { + if (services.mapilio && !_mapilio) { + _mapilio = services.mapilio; + _mapilio.event.on('loadedImages', throttledRedraw); + } else if (!services.mapilio && _mapilio) { + _mapilio = null; + } + + return _mapilio; + } + + + function showLayer() { + const service = getService(); + if (!service) return; + + editOn(); + + layer + .style('opacity', 0) + .transition() + .duration(250) + .style('opacity', 1) + .on('end', function () { dispatch.call('change'); }); + } + + + function hideLayer() { + 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 drawImages(selection) { + const enabled = svgMapilioImages.enabled; + const service = getService(); + + layer = selection.selectAll('.layer-mapilio') + .data(service ? [0] : []); + + layer.exit() + .remove(); + + const layerEnter = layer.enter() + .append('g') + .attr('class', 'layer-mapilio') + .style('display', enabled ? 'block' : 'none'); + + layerEnter + .append('g') + .attr('class', 'sequences'); + + layerEnter + .append('g') + .attr('class', 'markers'); + + layer = layerEnter + .merge(layer); + + if (enabled) { + if (service && ~~context.map().zoom() >= minZoom) { + editOn(); + service.loadImages(projection); + } else { + editOff(); + } + } + } + + + drawImages.enabled = function(_) { + if (!arguments.length) return svgMapilioImages.enabled; + svgMapilioImages.enabled = _; + if (svgMapilioImages.enabled) { + showLayer(); + context.photos().on('change.mapilio_images', null); + } else { + hideLayer(); + context.photos().on('change.mapilio_images', null); + } + dispatch.call('change'); + return this; + }; + + + drawImages.supported = function() { + return !!getService(); + }; + + + init(); + return drawImages; +}