Files
iD/modules/services/mapillary.js
Martin Raifer d5118b0fd6 make sure photos is properly highlighted when switching providers
previously, when the photo was already loaded in the mapillary viewer, then another photo from a different provider was displayed and the previous mapillary photo selected again, the "highlighted photo" marker was not drawn correctly, and the "set mapillary id from photo" button not properly updated
2025-04-29 17:19:55 +02:00

771 lines
24 KiB
JavaScript

/* global mapillary:false */
import { dispatch as d3_dispatch } from 'd3-dispatch';
import { select as d3_select } from 'd3-selection';
import Protobuf from 'pbf';
import RBush from 'rbush';
import { VectorTile } from '@mapbox/vector-tile';
import { geoExtent, geoScaleToZoom } from '../geo';
import { utilQsString, utilRebind, utilTiler, utilStringQs } from '../util';
import { services } from './';
const accessToken = 'MLY|4100327730013843|5bb78b81720791946a9a7b956c57b7cf';
const apiUrl = 'https://graph.mapillary.com/';
const baseTileUrl = 'https://tiles.mapillary.com/maps/vtp';
const mapFeatureTileUrl = `${baseTileUrl}/mly_map_feature_point/2/{z}/{x}/{y}?access_token=${accessToken}`;
const tileUrl = `${baseTileUrl}/mly1_public/2/{z}/{x}/{y}?access_token=${accessToken}`;
const trafficSignTileUrl = `${baseTileUrl}/mly_map_feature_traffic_sign/2/{z}/{x}/{y}?access_token=${accessToken}`;
const viewercss = 'mapillary-js/mapillary.css';
const viewerjs = 'mapillary-js/mapillary.js';
const minZoom = 14;
const dispatch = d3_dispatch('change', 'loadedImages', 'loadedSigns', 'loadedMapFeatures', 'bearingChanged', 'imageChanged');
let _loadViewerPromise;
let _mlyActiveImage;
let _mlyCache;
let _mlyFallback = false;
let _mlyHighlightedDetection;
let _mlyShowFeatureDetections = false;
let _mlyShowSignDetections = false;
let _mlyViewer;
let _mlyViewerFilter = ['all'];
let _isViewerOpen = false;
// Load all data for the specified type from Mapillary 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');
} else if (which === 'signs') {
dispatch.call('loadedSigns');
} else if (which === 'points') {
dispatch.call('loadedMapFeatures');
}
})
.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('image')) {
features = [];
cache = _mlyCache.images;
layer = vectorTile.layers.image;
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 = {
service: 'photo',
loc: loc,
captured_at: feature.properties.captured_at,
ca: feature.properties.compass_angle,
id: feature.properties.id,
is_pano: feature.properties.is_pano,
sequence_id: feature.properties.sequence_id,
};
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 = {
service: 'photo',
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);
}
}
if (vectorTile.layers.hasOwnProperty('traffic_sign')) {
features = [];
cache = _mlyCache[which];
layer = vectorTile.layers.traffic_sign;
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 = {
service: 'photo',
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);
}
}
}
// Get data from the API
function loadData(url) {
return fetch(url)
.then(function(response) {
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
return response.json();
})
.then(function(result) {
if (!result) {
return [];
}
return result.data || [];
});
}
// Partition viewport into higher zoom tiles
function partitionViewport(projection) {
const z = geoScaleToZoom(projection.scale());
const z2 = (Math.ceil(z * 2) / 2) + 2.5; // round to next 0.5 and add 2.5
const tiler = utilTiler().zoomExtent([z2, z2]);
return tiler.getTiles(projection)
.map(function(tile) { return tile.extent; });
}
// Return no more than `limit` results per partition.
function searchLimited(limit, projection, rtree) {
limit = limit || 5;
return partitionViewport(projection)
.reduce(function(result, extent) {
const found = rtree.search(extent.bbox())
.slice(0, limit)
.map(function(d) { return d.data; });
return (found.length ? result.concat(found) : result);
}, []);
}
export default {
// Initialize Mapillary
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: {} },
image_detections: { forImageId: {} },
signs: { rtree: new RBush() },
points: { rtree: new RBush() },
sequences: { rtree: new RBush(), lineString: {} },
requests: { loaded: {}, inflight: {} }
};
_mlyActiveImage = null;
},
// Get visible images
images: function(projection) {
const limit = 5;
return searchLimited(limit, projection, _mlyCache.images.rtree);
},
// Get visible traffic signs
signs: function(projection) {
const limit = 5;
return searchLimited(limit, projection, _mlyCache.signs.rtree);
},
// Get visible map (point) features
mapFeatures: function(projection) {
const limit = 5;
return searchLimited(limit, projection, _mlyCache.points.rtree);
},
// Get cached image by id
cachedImage: function(imageId) {
return _mlyCache.images.forImageId[imageId];
},
// Get visible sequences
sequences: function(projection) {
const viewport = projection.clipExtent();
const min = [viewport[0][0], viewport[1][1]];
const max = [viewport[1][0], viewport[0][1]];
const bbox = geoExtent(projection.invert(min), projection.invert(max)).bbox();
const sequenceIds = {};
let lineStrings = [];
_mlyCache.images.rtree.search(bbox)
.forEach(function(d) {
if (d.data.sequence_id) {
sequenceIds[d.data.sequence_id] = true;
}
});
Object.keys(sequenceIds).forEach(function(sequenceId) {
if (_mlyCache.sequences.lineString[sequenceId]) {
lineStrings = lineStrings.concat(_mlyCache.sequences.lineString[sequenceId]);
}
});
return lineStrings;
},
// Load images in the visible area
loadImages: function(projection) {
loadTiles('images', tileUrl, 14, projection);
},
// Load traffic signs in the visible area
loadSigns: function(projection) {
loadTiles('signs', trafficSignTileUrl, 14, projection);
},
// Load map (point) features in the visible area
loadMapFeatures: function(projection) {
loadTiles('points', mapFeatureTileUrl, 14, projection);
},
// Return a promise that resolves when the image viewer (Mapillary JS) library has finished loading
ensureViewerLoaded: function(context) {
if (_loadViewerPromise) return _loadViewerPromise;
// add mly-wrapper
const wrap = context.container().select('.photoviewer')
.selectAll('.mly-wrapper')
.data([0]);
wrap.enter()
.append('div')
.attr('id', 'ideditor-mly')
.attr('class', 'photo-wrapper mly-wrapper')
.classed('hide', true);
const that = this;
_loadViewerPromise = new Promise((resolve, reject) => {
let loadedCount = 0;
function loaded() {
loadedCount += 1;
// wait until both files are loaded
if (loadedCount === 2) resolve();
}
const head = d3_select('head');
// load mapillary-viewercss
head.selectAll('#ideditor-mapillary-viewercss')
.data([0])
.enter()
.append('link')
.attr('id', 'ideditor-mapillary-viewercss')
.attr('rel', 'stylesheet')
.attr('crossorigin', 'anonymous')
.attr('href', context.asset(viewercss))
.on('load.serviceMapillary', loaded)
.on('error.serviceMapillary', function() {
reject();
});
// load mapillary-viewerjs
head.selectAll('#ideditor-mapillary-viewerjs')
.data([0])
.enter()
.append('script')
.attr('id', 'ideditor-mapillary-viewerjs')
.attr('crossorigin', 'anonymous')
.attr('src', context.asset(viewerjs))
.on('load.serviceMapillary', loaded)
.on('error.serviceMapillary', function() {
reject();
});
})
.catch(function() {
_loadViewerPromise = null;
})
.then(function() {
that.initViewer(context);
});
return _loadViewerPromise;
},
// Load traffic sign image sprites
loadSignResources: function(context) {
context.ui().svgDefs.addSprites(['mapillary-sprite'], false /* don't override colors */ );
return this;
},
// Load map (point) feature image sprites
loadObjectResources: function(context) {
context.ui().svgDefs.addSprites(['mapillary-object-sprite'], false /* don't override colors */ );
return this;
},
// Remove previous detections in image viewer
resetTags: function() {
if (_mlyViewer && !_mlyFallback) {
_mlyViewer.getComponent('tag').removeAll();
}
},
// Show map feature detections in image viewer
showFeatureDetections: function(value) {
_mlyShowFeatureDetections = value;
if (!_mlyShowFeatureDetections && !_mlyShowSignDetections) {
this.resetTags();
}
},
// Show traffic sign detections in image viewer
showSignDetections: function(value) {
_mlyShowSignDetections = value;
if (!_mlyShowFeatureDetections && !_mlyShowSignDetections) {
this.resetTags();
}
},
// Apply filter to image viewer
filterViewer: function(context) {
const showsPano = context.photos().showsPanoramic();
const showsFlat = context.photos().showsFlat();
const fromDate = context.photos().fromDate();
const toDate = context.photos().toDate();
const filter = ['all'];
if (!showsPano) filter.push([ '!=', 'cameraType', 'spherical' ]);
if (!showsFlat && showsPano) filter.push(['==', 'pano', true]);
if (fromDate) {
filter.push(['>=', 'capturedAt', new Date(fromDate).getTime()]);
}
if (toDate) {
filter.push(['>=', 'capturedAt', new Date(toDate).getTime()]);
}
if (_mlyViewer) {
_mlyViewer.setFilter(filter);
}
_mlyViewerFilter = filter;
return filter;
},
// Make the image viewer visible
showViewer: function(context) {
const wrap = context.container().select('.photoviewer');
const isHidden = wrap.selectAll('.photo-wrapper.mly-wrapper.hide').size();
if (isHidden && _mlyViewer) {
for (const service of Object.values(services)) {
if (service === this) continue;
if (typeof service.hideViewer === 'function') {
service.hideViewer(context);
}
}
wrap.classed('hide', false)
.selectAll('.photo-wrapper.mly-wrapper')
.classed('hide', false);
_mlyViewer.resize();
}
_isViewerOpen = true;
return this;
},
// Hide the image viewer and resets map markers
hideViewer: function(context) {
_mlyActiveImage = null;
if (!_mlyFallback && _mlyViewer) {
_mlyViewer.getComponent('sequence').stop();
}
const viewer = context.container().select('.photoviewer');
if (!viewer.empty()) viewer.datum(null);
viewer
.classed('hide', true)
.selectAll('.photo-wrapper')
.classed('hide', true);
this.updateUrlImage(null);
dispatch.call('imageChanged');
dispatch.call('loadedMapFeatures');
dispatch.call('loadedSigns');
_isViewerOpen = false;
return this.setStyles(context, null);
},
// Get viewer status
isViewerOpen: function() {
return _isViewerOpen;
},
// Update the URL with current image id
updateUrlImage: function(imageId) {
const hash = utilStringQs(window.location.hash);
if (imageId) {
hash.photo = 'mapillary/' + imageId;
} else {
delete hash.photo;
}
window.history.replaceState(null, '', '#' + utilQsString(hash, true));
},
// Highlight the detection in the viewer that is related to the clicked map feature
highlightDetection: function(detection) {
if (detection) {
_mlyHighlightedDetection = detection.id;
}
return this;
},
// Initialize image viewer (Mapillar JS)
initViewer: function(context) {
if (!window.mapillary) return;
const opts = {
accessToken: accessToken,
component: {
cover: false,
keyboard: false,
tag: true
},
container: 'ideditor-mly',
};
// Disable components requiring WebGL support
if (!mapillary.isSupported() && mapillary.isFallbackSupported()) {
_mlyFallback = true;
opts.component = {
cover: false,
direction: false,
imagePlane: false,
keyboard: false,
mouse: false,
sequence: false,
tag: false,
image: true, // fallback
navigation: true // fallback
};
}
_mlyViewer = new mapillary.Viewer(opts);
_mlyViewer.on('image', imageChanged.bind(this));
_mlyViewer.on('bearing', bearingChanged);
if (_mlyViewerFilter) {
_mlyViewer.setFilter(_mlyViewerFilter);
}
// Register viewer resize handler
context.ui().photoviewer.on('resize.mapillary', function() {
if (_mlyViewer) _mlyViewer.resize();
});
// imageChanged: called after the viewer has changed images and is ready.
function imageChanged(node) {
this.resetTags();
const image = node.image;
this.setActiveImage(image);
this.setStyles(context, null);
const loc = [image.originalLngLat.lng, image.originalLngLat.lat];
context.map().centerEase(loc);
this.updateUrlImage(image.id);
if (_mlyShowFeatureDetections || _mlyShowSignDetections) {
this.updateDetections(image.id, `${apiUrl}/${image.id}/detections?access_token=${accessToken}&fields=id,image,geometry,value`);
}
dispatch.call('imageChanged');
}
// bearingChanged: called when the bearing changes in the image viewer.
function bearingChanged(e) {
dispatch.call('bearingChanged', undefined, e);
}
},
// Move to an image
selectImage: function(context, imageId) {
if (_mlyViewer && imageId) {
_mlyViewer.moveTo(imageId)
.then(image => this.setActiveImage(image))
.catch(function(e) {
console.error('mly3', e); // eslint-disable-line no-console
});
}
return this;
},
// Return the currently displayed image
getActiveImage: function() {
return _mlyActiveImage;
},
// Return a list of detection objects for the given id
getDetections: function(id) {
return loadData(`${apiUrl}/${id}/detections?access_token=${accessToken}&fields=id,value,image`);
},
// Set the currently visible image
setActiveImage: function(image) {
if (image) {
_mlyActiveImage = {
ca: image.originalCompassAngle,
id: image.id,
loc: [image.originalLngLat.lng, image.originalLngLat.lat],
is_pano: image.cameraType === 'spherical',
sequence_id: image.sequenceId
};
} else {
_mlyActiveImage = null;
}
},
// 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-mapillary .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-mapillary .sequence')
.classed('highlighted', function(d) { return d.properties.id === hoveredSequenceId; })
.classed('currentView', function(d) { return d.properties.id === selectedSequenceId; });
return this;
},
// Get detections for the current image and shows them in the image viewer
updateDetections: function(imageId, url) {
if (!_mlyViewer || _mlyFallback) return;
if (!imageId) return;
const cache = _mlyCache.image_detections;
if (cache.forImageId[imageId]) {
showDetections(_mlyCache.image_detections.forImageId[imageId]);
} else {
loadData(url)
.then(detections => {
detections.forEach(function(detection) {
if (!cache.forImageId[imageId]) {
cache.forImageId[imageId] = [];
}
cache.forImageId[imageId].push({
geometry: detection.geometry,
id: detection.id,
image_id: imageId,
value:detection.value
});
});
showDetections(_mlyCache.image_detections.forImageId[imageId] || []);
});
}
// Create a tag for each detection and shows it in the image viewer
function showDetections(detections) {
const tagComponent = _mlyViewer.getComponent('tag');
detections.forEach(function(data) {
const tag = makeTag(data);
if (tag) {
tagComponent.add([tag]);
}
});
}
// Create a Mapillary JS tag object
function makeTag(data) {
const valueParts = data.value.split('--');
if (!valueParts.length) return;
let tag;
let text;
let color = 0xffffff;
if (_mlyHighlightedDetection === data.id) {
color = 0xffff00;
text = valueParts[1];
if (text === 'flat' || text === 'discrete' || text === 'sign') {
text = valueParts[2];
}
text = text.replace(/-/g, ' ');
text = text.charAt(0).toUpperCase() + text.slice(1);
_mlyHighlightedDetection = null;
}
var decodedGeometry = window.atob(data.geometry);
var uintArray = new Uint8Array(decodedGeometry.length);
for (var i = 0; i < decodedGeometry.length; i++) {
uintArray[i] = decodedGeometry.charCodeAt(i);
}
const tile = new VectorTile(new Protobuf(uintArray.buffer));
const layer = tile.layers['mpy-or'];
const geometries = layer.feature(0).loadGeometry();
const polygon = geometries.map(ring =>
ring.map(point =>
[point.x / layer.extent, point.y / layer.extent]));
tag = new mapillary.OutlineTag(
data.id,
new mapillary.PolygonGeometry(polygon[0]),
{
text: text,
textColor: color,
lineColor: color,
lineWidth: 2,
fillColor: color,
fillOpacity: 0.3,
}
);
return tag;
}
},
// Return the current cache
cache: function() {
return _mlyCache;
}
};