Add Panoramax as new street level imagery provider

This commit is contained in:
Martin Raifer
2024-07-29 16:37:53 +02:00
committed by GitHub
16 changed files with 1231 additions and 31 deletions
+1
View File
@@ -45,6 +45,7 @@ _Breaking developer changes, which may affect downstream projects or sites that
* Sort preset-specific optional fields before universal fields in "Add field" dropdown ([#10181], thanks [@zbycz])
#### :scissors: Operations
#### :camera: Street-Level
* Add Panoramax as new street level imagery provider ([#9941], thanks [@mattiapezzotti])
#### :white_check_mark: Validation
* Drop deprecated validation service _ImproveOSM_ ([#10302], thanks [@arch0345])
#### :bug: Bugfixes
+63 -11
View File
@@ -104,6 +104,7 @@
.photo-attribution span {
padding: 4px 2px;
color: #fff;
text-wrap: nowrap;
}
/* markers and sequences */
@@ -175,7 +176,7 @@
.sequence {
fill: none;
stroke-width: 2;
stroke-opacity: 0.4;
stroke-opacity: 0.6;
}
.sequence.highlighted,
.sequence.currentView {
@@ -310,7 +311,57 @@
width: 100%;
height: 100%;
object-fit: cover;
overflow: hidden
overflow: hidden;
}
/* panoramax Image Layer */
.layer-panoramax {
pointer-events: none;
}
.layer-panoramax .viewfield-group * {
fill: #1234ae;
stroke: #ffffff;
stroke-opacity: .6;
fill-opacity: .6;
}
.layer-panoramax .sequence {
stroke: #1234ae;
}
.photo-controls-panoramax {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.photo-controls-panoramax button {
padding:0 6px;
pointer-events: initial;
}
label.panoramax-hd {
float: left;
cursor: pointer;
}
.panoramax-hd span {
margin-top: 2px;
}
.panoramax-hd input[type="checkbox"] {
width: 12px;
height: 12px;
margin: 0 5px;
}
.slider-wrap {
display: inline-block;
}
.year-datalist {
display: flex;
justify-content: space-between;
}
.list-option-date-slider{
direction: rtl
}
@@ -343,7 +394,8 @@
}
.ms-wrapper .pnlm-compass.pnlm-control,
.vegbilder-wrapper .pnlm-compass.pnlm-control {
.vegbilder-wrapper .pnlm-compass.pnlm-control,
.panoramax-wrapper .pnlm-compass.pnlm-control {
width: 26px;
height: 26px;
left: 4px;
@@ -435,19 +487,18 @@ label.streetside-hires {
.photo-wrapper {
position: relative;
background-color: #000;
}
.photoviewer .plane-frame {
display: block;
overflow: hidden;
height: 100%;
width: 100%;
background-image: url(img/loader-black.gif);
background-position: center;
background-repeat: no-repeat;
}
.photoviewer .plane-frame > img.plane-photo{
.photoviewer .plane-frame {
height: 100%;
width: 100%;
transform-origin: 0 0;
}
.photoviewer .plane-frame > img.plane-photo {
width: auto;
height: 100%;
transform-origin: 0 0;
@@ -592,3 +643,4 @@ label.streetside-hires {
border-radius: 4px;
cursor: pointer;
}
+6
View File
@@ -1424,6 +1424,12 @@ en:
mapilio:
title: Mapilio
tooltip: "Street-level photos from Mapilio"
panoramax:
title: Panoramax
tooltip: "Street-level photos from Panoramax"
report: "Report"
captured_by: "Captured by {username}"
hd: "High resolution"
street_side:
minzoom_tooltip: "Zoom in to see street-side photos"
local_photos:
+2 -1
View File
@@ -253,7 +253,8 @@ export function rendererBackground(context) {
'mapillary-signs': 'Mapillary Signs',
kartaview: 'KartaView Images',
vegbilder: 'Norwegian Road Administration Images',
mapilio: 'Mapilio Images'
mapilio: 'Mapilio Images',
panoramax: 'Panoramax Images'
};
for (let layerID in photoOverlayLayers) {
+4 -4
View File
@@ -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', 'mapilio', 'vegbilder'];
var _layerIDs = ['streetside', 'mapillary', 'mapillary-map-features', 'mapillary-signs', 'kartaview', 'mapilio', 'vegbilder', 'panoramax'];
var _allPhotoTypes = ['flat', 'panoramic'];
var _shownPhotoTypes = _allPhotoTypes.slice(); // shallow copy
var _dateFilters = ['fromDate', 'toDate'];
@@ -119,16 +119,16 @@ export function rendererPhotos(context) {
}
photos.shouldFilterByDate = function() {
return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside') || showsLayer('vegbilder');
return showsLayer('mapillary') || showsLayer('kartaview') || showsLayer('streetside') || showsLayer('vegbilder') || showsLayer('panoramax');
};
photos.shouldFilterByPhotoType = function() {
return showsLayer('mapillary') ||
(showsLayer('streetside') && showsLayer('kartaview')) || showsLayer('vegbilder');
(showsLayer('streetside') && showsLayer('kartaview')) || showsLayer('vegbilder') || showsLayer('panoramax');
};
photos.shouldFilterByUsername = function() {
return !showsLayer('mapillary') && showsLayer('kartaview') && !showsLayer('streetside');
return !showsLayer('mapillary') && showsLayer('kartaview') && !showsLayer('streetside') || showsLayer('panoramax');
};
photos.showsPhotoType = function(val) {
+5 -2
View File
@@ -14,6 +14,7 @@ import serviceVectorTile from './vector_tile';
import serviceWikidata from './wikidata';
import serviceWikipedia from './wikipedia';
import serviceMapilio from './mapilio';
import servicePanoramax from './panoramax';
export let services = {
@@ -32,7 +33,8 @@ export let services = {
vectorTile: serviceVectorTile,
wikidata: serviceWikidata,
wikipedia: serviceWikipedia,
mapilio: serviceMapilio
mapilio: serviceMapilio,
panoramax: servicePanoramax
};
export {
@@ -51,5 +53,6 @@ export {
serviceVectorTile,
serviceWikidata,
serviceWikipedia,
serviceMapilio
serviceMapilio,
servicePanoramax
};
+1 -1
View File
@@ -119,7 +119,7 @@ export default {
let newSceneOptions = {
showFullscreenCtrl: false,
autoLoad: false,
compass: true,
compass: false,
yaw: 0,
type: 'equirectangular',
preview: data.preview_path,
+636
View File
@@ -0,0 +1,636 @@
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, utilQsString, utilStringQs, utilUniqueDomId} from '../util';
import { geoExtent, geoScaleToZoom } from '../geo';
import { t, localizer } from '../core/localizer';
import pannellumPhotoFrame from './pannellum_photo';
import planePhotoFrame from './plane_photo';
const apiUrl = 'https://api.panoramax.xyz/';
const tileUrl = apiUrl + 'api/map/{z}/{x}/{y}.mvt';
const imageDataUrl = apiUrl + 'api/collections/{collectionId}/items/{itemId}';
const userIdUrl = apiUrl + 'api/users/search?q={username}';
const usernameURL = apiUrl + 'api/users/{userId}';
const viewerUrl = apiUrl;
const highDefinition = 'hd';
const standardDefinition = 'sd';
const pictureLayer = 'pictures';
const sequenceLayer = 'sequences';
const minZoom = 10;
const imageMinZoom = 15;
const lineMinZoom = 10;
const dispatch = d3_dispatch('loadedImages', 'loadedLines', 'viewerChanged');
let _cache;
let _loadViewerPromise;
let _definition = standardDefinition;
let _isHD = false;
let _planeFrame;
let _pannellumFrame;
let _currentFrame;
let _oldestDate;
let _currentScene = {
currentImage : null,
nextImage : null,
prevImage : null
};
let _activeImage;
// 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);
}, []);
}
// Load all data for the specified type from Panoramax 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 = _cache.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.byteLength === 0) {
throw new Error('No Data');
}
loadTileDataToCache(data, tile, which);
if (which === 'images') {
dispatch.call('loadedImages');
} else {
dispatch.call('loadedLines');
}
})
.catch(function (e) {
if (e.message === 'No Data') {
cache.loaded[tileId] = true;
} else {
console.error(e); // eslint-disable-line no-console
}
});
}
function loadTileDataToCache(data, tile) {
const vectorTile = new VectorTile(new Protobuf(data));
let features,
cache,
layer,
i,
feature,
loc,
d;
if (vectorTile.layers.hasOwnProperty(pictureLayer)) {
features = [];
cache = _cache.images;
layer = vectorTile.layers[pictureLayer];
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,
capture_time: feature.properties.ts,
id: feature.properties.id,
account_id: feature.properties.account_id,
sequence_id: feature.properties.sequences.split('\"')[1],
heading: parseInt(feature.properties.heading, 10),
image_path: '',
resolution: feature.properties.resolution,
isPano: feature.properties.type === 'equirectangular',
model: feature.properties.model,
};
cache.forImageId[d.id] = d;
features.push({
minX: loc[0], minY: loc[1], maxX: loc[0], maxY: loc[1], data: d
});
if (_oldestDate){
if (d.capture_time < _oldestDate){
_oldestDate = d.capture_time;
}
} else {
_oldestDate = d.capture_time;
}
}
if (cache.rtree) {
cache.rtree.load(features);
}
}
if (vectorTile.layers.hasOwnProperty(sequenceLayer)) {
cache = _cache.sequences;
layer = vectorTile.layers[sequenceLayer];
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 (_oldestDate){
if (feature.properties.date < _oldestDate){
_oldestDate = feature.properties.date;
}
} else {
_oldestDate = feature.properties.date;
}
}
}
}
async function getImageData(collection_id, image_id){
const requestUrl = imageDataUrl.replace('{collectionId}', collection_id)
.replace('{itemId}', image_id);
const response = await fetch(requestUrl, { method: 'GET' });
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
const data = await response.json();
return data;
}
async function getUsername(user_id){
const requestUrl = usernameURL.replace('{userId}', user_id);
const response = await fetch(requestUrl, { method: 'GET' });
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
const data = await response.json();
return data.name;
}
export default {
init: function() {
if (!_cache) {
this.reset();
}
this.event = utilRebind(this, dispatch, 'on');
},
reset: function() {
if (_cache) {
Object.values(_cache.requests.inflight).forEach(function(request) { request.abort(); });
}
_cache = {
images: { rtree: new RBush(), forImageId: {} },
sequences: { rtree: new RBush(), lineString: {} },
requests: { loaded: {}, inflight: {} }
};
_currentScene.currentImage = null;
_activeImage = null;
},
// Get visible images
images: function(projection) {
const limit = 5;
return searchLimited(limit, projection, _cache.images.rtree);
},
cachedImage: function(imageKey) {
return _cache.images.forImageId[imageKey];
},
// Load images in the visible area
loadImages: function(projection) {
loadTiles('images', tileUrl, imageMinZoom, projection);
},
// Load line in the visible area
loadLines: function(projection) {
loadTiles('line', tileUrl, lineMinZoom, projection);
},
getUserIds: async function(usernames) {
const requestUrls = usernames.map(username =>
userIdUrl.replace('{username}', username));
const responses = await Promise.all(requestUrls.map(requestUrl =>
fetch(requestUrl, { method: 'GET' })));
if (responses.some(response => !response.ok)) {
const response = responses.find(response => !response.ok);
throw new Error(response.status + ' ' + response.statusText);
}
const data = await Promise.all(responses.map(response => response.json()));
// in panoramax, a username can have multiple ids, when the same name is
// used on different servers
return data.flatMap((d, i) => d.features.filter(f => f.name === usernames[i]).map(f => f.id));
},
getOldestDate: function(){
return _oldestDate;
},
// Get visible sequences
sequences: function(projection, zoom) {
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 = [];
if (zoom >= imageMinZoom){
_cache.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 (_cache.sequences.lineString[sequenceId]) {
lineStrings = lineStrings.concat(_cache.sequences.lineString[sequenceId]);
}
});
return lineStrings;
}
if (zoom >= lineMinZoom){
Object.keys(_cache.sequences.lineString).forEach(function(sequenceId) {
lineStrings = lineStrings.concat(_cache.sequences.lineString[sequenceId]);
});
}
return lineStrings;
},
// Set the currently visible image
setActiveImage: function(image) {
if (image) {
_activeImage = {
id: image.id,
sequence_id: image.sequence_id
};
} else {
_activeImage = null;
}
},
getActiveImage: function(){
return _activeImage;
},
// 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 = _activeImage && _activeImage.sequence_id;
const selectedImageId = _activeImage && _activeImage.id;
const markers = context.container().selectAll('.layer-panoramax .viewfield-group');
const sequences = context.container().selectAll('.layer-panoramax .sequence');
markers
.classed('highlighted', function(d) { return d.id === hoveredImageId; })
.classed('hovered', function(d) { return d.id === hoveredImageId; })
.classed('currentView', function(d) { return d.id === selectedImageId; });
sequences
.classed('highlighted', function(d) { return d.sequence_id === hoveredSequenceId; })
.classed('currentView', function(d) { return d.sequence_id === selectedSequenceId; });
// update viewfields if needed
context.container().selectAll('.layer-panoramax .viewfield-group .viewfield')
.attr('d', viewfieldPath);
function viewfieldPath() {
let d = this.parentNode.__data__;
if (d.isPano && d.id !== selectedImageId) {
return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0';
} else {
return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z';
}
}
return this;
},
updateUrlImage: function(imageKey) {
if (!window.mocha) {
var hash = utilStringQs(window.location.hash);
if (imageKey) {
hash.photo = 'panoramax/' + imageKey;
} else {
delete hash.photo;
}
window.location.replace('#' + utilQsString(hash, true));
}
},
selectImage: function (context, id) {
let that = this;
let d = that.cachedImage(id);
that.setActiveImage(d);
that.updateUrlImage(d.id);
const viewerLink = `${viewerUrl}#pic=${d.id}&focus=pic`;
let viewer = context.container()
.select('.photoviewer');
if (!viewer.empty()) viewer.datum(d);
this.setStyles(context, null);
if (!d) return this;
let wrap = context.container()
.select('.photoviewer .panoramax-wrapper');
let attribution = wrap.selectAll('.photo-attribution').text('');
let line1 = attribution
.append('div')
.attr('class', 'attribution-row');
const hdDomId = utilUniqueDomId('panoramax-hd');
let label = line1
.append('label')
.attr('for', hdDomId)
.attr('class', 'panoramax-hd');
label
.append('input')
.attr('type', 'checkbox')
.attr('id', hdDomId)
.property('checked', _isHD)
.on('click', (d3_event) => {
d3_event.stopPropagation();
_isHD = !_isHD;
_definition = _isHD ? highDefinition : standardDefinition;
that.selectImage(context, d.id)
.showViewer(context);
});
label
.append('span')
.call(t.append('panoramax.hd'));
if (d.capture_time) {
attribution
.append('span')
.attr('class', 'captured_at')
.text(localeDateString(d.capture_time));
attribution
.append('span')
.text('|');
}
attribution
.append('a')
.attr('class', 'report-photo')
.attr('href', 'mailto:signalement.ign@panoramax.fr')
.call(t.append('panoramax.report'));
attribution
.append('span')
.text('|');
attribution
.append('a')
.attr('class', 'image-link')
.attr('target', '_blank')
.attr('href', viewerLink)
.text('panoramax.xyz');
getImageData(d.sequence_id, d.id).then(function(data){
_currentScene = {
currentImage: null,
nextImage: null,
prevImage: null
};
_currentScene.currentImage = data.assets[_definition];
const nextIndex = data.links.findIndex(x => x.rel === 'next');
const prevIndex = data.links.findIndex(x => x.rel === 'prev');
if (nextIndex !== -1){
_currentScene.nextImage = data.links[nextIndex];
}
if (prevIndex !== -1){
_currentScene.prevImage = data.links[prevIndex];
}
d.image_path = _currentScene.currentImage.href;
wrap
.selectAll('button.back')
.classed('hide', _currentScene.prevImage === null);
wrap
.selectAll('button.forward')
.classed('hide', _currentScene.nextImage === null);
_currentFrame = d.isPano ? _pannellumFrame : _planeFrame;
_currentFrame
.selectPhoto(d, true)
.showPhotoFrame(wrap);
});
function localeDateString(s) {
if (!s) return null;
var options = { day: 'numeric', month: 'short', year: 'numeric' };
var d = new Date(s);
if (isNaN(d.getTime())) return null;
return d.toLocaleDateString(localizer.localeCode(), options);
}
if (d.account_id) {
attribution
.append('span')
.text('|');
let line2 = attribution
.append('span')
.attr('class', 'attribution-row');
getUsername(d.account_id).then(function(username){
line2
.append('span')
.attr('class', 'captured_by')
.text(t('panoramax.captured_by', {username}));
});
}
return this;
},
photoFrame: function() {
return _currentFrame;
},
ensureViewerLoaded: function(context) {
let that = this;
let imgWrap = context.container()
.select('#ideditor-viewer-panoramax-simple > img');
if (!imgWrap.empty()) {
imgWrap.remove();
}
if (_loadViewerPromise) return _loadViewerPromise;
let wrap = context.container()
.select('.photoviewer')
.selectAll('.panoramax-wrapper')
.data([0]);
let wrapEnter = wrap.enter()
.append('div')
.attr('class', 'photo-wrapper panoramax-wrapper')
.classed('hide', true)
.on('dblclick.zoom', null);
wrapEnter
.append('div')
.attr('class', 'photo-attribution fillD');
const controlsEnter = wrapEnter
.append('div')
.attr('class', 'photo-controls-wrap')
.append('div')
.attr('class', 'photo-controls-panoramax');
controlsEnter
.append('button')
.classed('back', true)
.on('click.back', step(-1))
.text('◄');
controlsEnter
.append('button')
.classed('forward', true)
.on('click.forward', step(1))
.text('►');
// Register viewer resize handler
_loadViewerPromise = Promise.all([
pannellumPhotoFrame.init(context, wrapEnter),
planePhotoFrame.init(context, wrapEnter)
]).then(([pannellumPhotoFrame, planePhotoFrame]) => {
_pannellumFrame = pannellumPhotoFrame;
_pannellumFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged'));
_planeFrame = planePhotoFrame;
_planeFrame.event.on('viewerChanged', () => dispatch.call('viewerChanged'));
});
function step(stepBy) {
return function () {
if (!_currentScene.currentImage) return;
let nextId;
if (stepBy === 1) nextId = _currentScene.nextImage.id;
else nextId = _currentScene.prevImage.id;
if (!nextId) return;
const nextImage = _cache.images.forImageId[nextId];
context.map().centerEase(nextImage.loc);
that.selectImage(context, nextImage.id);
};
}
return _loadViewerPromise;
},
showViewer: function (context) {
let wrap = context.container().select('.photoviewer')
.classed('hide', false);
let isHidden = wrap.selectAll('.photo-wrapper.panoramax-wrapper.hide').size();
if (isHidden) {
wrap
.selectAll('.photo-wrapper:not(.panoramax-wrapper)')
.classed('hide', true);
wrap
.selectAll('.photo-wrapper.panoramax-wrapper')
.classed('hide', false);
}
return this;
},
hideViewer: function (context) {
let viewer = context.container().select('.photoviewer');
if (!viewer.empty()) viewer.datum(null);
this.updateUrlImage(null);
viewer
.classed('hide', true)
.selectAll('.photo-wrapper')
.classed('hide', true);
context.container().selectAll('.viewfield-group, .sequence, .icon-sign')
.classed('currentView', false);
this.setActiveImage();
return this.setStyles(context, null);
},
cache: function() {
return _cache;
}
};
+5 -6
View File
@@ -85,16 +85,15 @@ export default {
return this;
},
selectPhoto: function (data, keepOrientation) {
selectPhoto: function (data) {
dispatch.call('viewerChanged');
loadImage(_photo, '');
loadImage(_photo, data.image_path)
.then(() => {
if (!keepOrientation) {
imgZoom = zoomBeahvior();
_wrapper.call(imgZoom);
_wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0));
}
imgZoom = zoomBeahvior();
_wrapper.call(imgZoom);
_wrapper.call(imgZoom.transform, d3_zoomIdentity.translate(-_widthOverflow / 2, 0));
});
return this;
},
+1
View File
@@ -29,3 +29,4 @@ export { svgTouch } from './touch.js';
export { svgTurns } from './turns.js';
export { svgVertices } from './vertices.js';
export { svgMapilioImages } from './mapilio_images.js';
export { svgPanoramaxImages } from './panoramax_images.js';
+2
View File
@@ -15,6 +15,7 @@ import { svgMapillarySigns } from './mapillary_signs';
import { svgMapillaryMapFeatures } from './mapillary_map_features';
import { svgKartaviewImages } from './kartaview_images';
import { svgMapilioImages } from './mapilio_images';
import { svgPanoramaxImages } from './panoramax_images';
import { svgOsm } from './osm';
import { svgNotes } from './notes';
import { svgTouch } from './touch';
@@ -39,6 +40,7 @@ export function svgLayers(projection, context) {
{ id: 'kartaview', layer: svgKartaviewImages(projection, context, dispatch) },
{ id: 'mapilio', layer: svgMapilioImages(projection, context, dispatch) },
{ id: 'vegbilder', layer: svgVegbilder(projection, context, dispatch) },
{ id: 'panoramax', layer: svgPanoramaxImages(projection, context, dispatch) },
{ id: 'local-photos', layer: svgLocalPhotos(projection, context, dispatch) },
{ id: 'debug', layer: svgDebug(projection, context, dispatch) },
{ id: 'geolocate', layer: svgGeolocate(projection, context, dispatch) },
+1 -1
View File
@@ -229,7 +229,7 @@ export function svgMapilioImages(projection, context, dispatch) {
svgMapilioImages.enabled = _;
if (svgMapilioImages.enabled) {
showLayer();
context.photos().on('change.mapilio_images', null);
context.photos().on('change.mapilio_images', update);
} else {
hideLayer();
context.photos().on('change.mapilio_images', null);
+382
View File
@@ -0,0 +1,382 @@
import _throttle from 'lodash-es/throttle';
import { select as d3_select } from 'd3-selection';
import { services } from '../services';
import {svgPath, svgPointTransform} from './helpers';
export function svgPanoramaxImages(projection, context, dispatch) {
const throttledRedraw = _throttle(function () { dispatch.call('change'); }, 1000);
const imageMinZoom = 15;
const lineMinZoom = 10;
const viewFieldZoomLevel = 18;
let layer = d3_select(null);
let _panoramax;
let _viewerYaw = 0;
let _selectedSequence;
let _activeUsernameFilter;
let _activeIds;
function init() {
if (svgPanoramaxImages.initialized) return;
svgPanoramaxImages.enabled = false;
svgPanoramaxImages.initialized = true;
}
function getService() {
if (services.panoramax && !_panoramax) {
_panoramax = services.panoramax;
_panoramax.event
.on('viewerChanged', viewerChanged)
.on('loadedLines', throttledRedraw)
.on('loadedImages', throttledRedraw);
} else if (!services.panoramax && _panoramax) {
_panoramax = null;
}
return _panoramax;
}
async function filterImages(images) {
const showsPano = context.photos().showsPanoramic();
const showsFlat = context.photos().showsFlat();
const fromDate = context.photos().fromDate();
const toDate = context.photos().toDate();
const username = context.photos().usernames();
const service = getService();
if (!showsPano || !showsFlat) {
images = images.filter(function(image) {
if (image.isPano) return showsPano;
return showsFlat;
});
}
if (fromDate) {
images = images.filter(function(image) {
return new Date(image.capture_time).getTime() >= new Date(fromDate).getTime();
});
}
if (toDate) {
images = images.filter(function(image) {
return new Date(image.capture_time).getTime() <= new Date(toDate).getTime();
});
}
if (username && service) {
if (_activeUsernameFilter !== username) {
_activeUsernameFilter = username;
const tempIds = await service.getUserIds(username);
_activeIds = {};
tempIds.forEach(id => {
_activeIds[id] = true;
});
}
images = images.filter(function(image) {
return _activeIds[image.account_id];
});
}
return images;
}
async function filterSequences(sequences) {
const showsPano = context.photos().showsPanoramic();
const showsFlat = context.photos().showsFlat();
const fromDate = context.photos().fromDate();
const toDate = context.photos().toDate();
const username = context.photos().usernames();
const service = getService();
if (!showsPano || !showsFlat) {
sequences = sequences.filter(function(sequence) {
if (sequence.properties.type === 'equirectangular') return showsPano;
return showsFlat;
});
}
if (fromDate) {
sequences = sequences.filter(function(sequence) {
return new Date(sequence.properties.date).getTime() >= new Date(fromDate).getTime().toString();
});
}
if (toDate) {
sequences = sequences.filter(function(sequence) {
return new Date(sequence.properties.date).getTime() <= new Date(toDate).getTime().toString();
});
}
if (username && service) {
if (_activeUsernameFilter !== username) {
_activeUsernameFilter = username;
const tempIds = await service.getUserIds(username);
_activeIds = {};
tempIds.forEach(id => {
_activeIds[id] = true;
});
}
sequences = sequences.filter(function(sequence) {
return _activeIds[sequence.properties.account_id];
});
}
return sequences;
}
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 transform(d) {
let t = svgPointTransform(projection)(d);
var rot = d.heading + _viewerYaw;
if (rot) {
t += ' rotate(' + Math.floor(rot) + ',0,0)';
}
return t;
}
function editOn() {
layer.style('display', 'block');
}
function editOff() {
layer.selectAll('.viewfield-group').remove();
layer.style('display', 'none');
}
function click(d3_event, image) {
const service = getService();
if (!service) return;
if (image.sequence_id !== _selectedSequence) {
_viewerYaw = 0; // reset
}
_selectedSequence = image.sequence_id;
service
.ensureViewerLoaded(context)
.then(function() {
service
.selectImage(context, image.id)
.showViewer(context);
});
context.map().centerEase(image.loc);
}
function mouseover(d3_event, image) {
const service = getService();
if (service) service.setStyles(context, image);
}
function mouseout() {
const service = getService();
if (service) service.setStyles(context, null);
}
async function update() {
const zoom = ~~context.map().zoom();
const showViewfields = (zoom >= viewFieldZoomLevel);
const service = getService();
let sequences = (service ? service.sequences(projection, zoom) : []);
let images = (service ? service.images(projection) : []);
images = await filterImages(images);
sequences = await filterSequences(sequences, service);
let traces = layer.selectAll('.sequences').selectAll('.sequence')
.data(sequences, function(d) { return d.id; });
// exit
traces.exit()
.remove();
traces.enter()
.append('path')
.attr('class', 'sequence')
.merge(traces)
.attr('d', svgPath(projection).geojson);
const groups = layer.selectAll('.markers').selectAll('.viewfield-group')
.data(images, function(d) { return d.id; });
// exit
groups.exit()
.remove();
// enter
const groupsEnter = groups.enter()
.append('g')
.attr('class', 'viewfield-group')
.on('mouseenter', mouseover)
.on('mouseleave', mouseout)
.on('click', click);
groupsEnter
.append('g')
.attr('class', 'viewfield-scale');
// update
const markers = groups
.merge(groupsEnter)
.sort(function(a, b) {
return b.loc[1] - a.loc[1]; // sort Y
})
.attr('transform', transform)
.select('.viewfield-scale');
markers.selectAll('circle')
.data([0])
.enter()
.append('circle')
.attr('dx', '0')
.attr('dy', '0')
.attr('r', '6');
const viewfields = markers.selectAll('.viewfield')
.data(showViewfields ? [0] : []);
viewfields.exit()
.remove();
viewfields.enter()
.insert('path', 'circle')
.attr('class', 'viewfield')
.attr('transform', 'scale(1.5,1.5),translate(-8, -13)')
.attr('d', viewfieldPath);
function viewfieldPath() {
if (this.parentNode.__data__.isPano) {
return 'M 8,13 m -10,0 a 10,10 0 1,0 20,0 a 10,10 0 1,0 -20,0';
} else {
return 'M 6,9 C 8,8.4 8,8.4 10,9 L 16,-2 C 12,-5 4,-5 0,-2 z';
}
}
}
function viewerChanged() {
const service = getService();
if (!service) return;
const frame = service.photoFrame();
// update viewfield rotation
_viewerYaw = frame.getYaw();
// avoid updating if the map is currently transformed
// e.g. during drags or easing.
if (context.map().isTransformed()) return;
layer.selectAll('.viewfield-group.currentView')
.attr('transform', transform);
}
function drawImages(selection) {
const enabled = svgPanoramaxImages.enabled;
const service = getService();
layer = selection.selectAll('.layer-panoramax')
.data(service ? [0] : []);
layer.exit()
.remove();
const layerEnter = layer.enter()
.append('g')
.attr('class', 'layer-panoramax')
.style('display', enabled ? 'block' : 'none');
layerEnter
.append('g')
.attr('class', 'sequences');
layerEnter
.append('g')
.attr('class', 'markers');
layer = layerEnter
.merge(layer);
if (enabled) {
let zoom = ~~context.map().zoom();
if (service){
if (zoom >= imageMinZoom) {
editOn();
update();
service.loadImages(projection);
} else if (zoom >= lineMinZoom) {
editOn();
update();
service.loadLines(projection);
} else {
editOff();
}
} else {
editOff();
}
}
}
drawImages.enabled = function(_) {
if (!arguments.length) return svgPanoramaxImages.enabled;
svgPanoramaxImages.enabled = _;
if (svgPanoramaxImages.enabled) {
showLayer();
context.photos().on('change.panoramax_images', update);
} else {
hideLayer();
context.photos().on('change.panoramax_images', null);
}
dispatch.call('change');
return this;
};
drawImages.supported = function() {
return !!getService();
};
drawImages.rendered = function(zoom) {
return zoom >= lineMinZoom;
};
init();
return drawImages;
}
+1
View File
@@ -28,6 +28,7 @@ export function uiPhotoviewer(context) {
if (services.mapillary) { services.mapillary.hideViewer(context); }
if (services.kartaview) { services.kartaview.hideViewer(context); }
if (services.mapilio) { services.mapilio.hideViewer(context); }
if (services.panoramax) { services.panoramax.hideViewer(context); }
if (services.vegbilder) { services.vegbilder.hideViewer(context); }
})
.append('div')
+115
View File
@@ -0,0 +1,115 @@
describe('iD.servicePanoramax', function() {
var dimensions = [64, 64];
var context, panoramax;
before(function() {
iD.services.panoramax = iD.servicePanoramax;
fetchMock.reset();
});
after(function() {
delete iD.services.panoramax;
});
beforeEach(function() {
context = iD.coreContext().assetPath('../dist/').init();
context.projection
.scale(iD.geoZoomToScale(14))
.translate([-116508, 0]) // 10,0
.clipExtent([[0,0], dimensions]);
panoramax = iD.services.panoramax;
panoramax.reset();
fetchMock.reset();
});
afterEach(function() {
fetchMock.reset();
});
describe('#init', function() {
it('Initializes cache one time', function() {
var cache = panoramax.cache();
expect(cache).to.have.property('images');
expect(cache).to.have.property('sequences');
panoramax.init();
var cache2 = panoramax.cache();
expect(cache).to.equal(cache2);
});
});
describe('#reset', function() {
it('resets cache and image', function() {
panoramax.cache().foo = 'bar';
panoramax.setActiveImage(context, {key: 'baz'});
panoramax.reset();
expect(panoramax.cache()).to.not.have.property('foo');
expect(panoramax.getActiveImage()).to.be.null;
});
});
describe('#images', function() {
it('returns images in the visible map area', function() {
var features = [
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } },
{ minX: 10, minY: 1, maxX: 10, maxY: 1, data: { id: '2', loc: [10,1], heading: 90, sequence_id: '100', account_id: '2' } }
];
panoramax.cache().images.rtree.load(features);
var res = panoramax.images(context.projection);
expect(res).to.deep.eql([
{ id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' },
{ id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' }
]);
});
it('limits results no more than 5 stacked images in one spot', function() {
var features = [
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '2', loc: [10,0], heading: 90, sequence_id: '100', account_id: '2' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '3', loc: [10,0], heading: 90, sequence_id: '100', account_id: '3' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '4', loc: [10,0], heading: 90, sequence_id: '100', account_id: '4' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '5', loc: [10,0], heading: 90, sequence_id: '100', account_id: '5' } }
];
panoramax.cache().images.rtree.load(features);
var res = panoramax.images(context.projection);
expect(res).to.have.length.of.at.most(5);
});
});
describe('#sequences', function() {
it('returns sequence linestrings in the visible map area', function() {
var features = [
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '0', loc: [10,0], heading: 90, sequence_id: '100', account_id: '0' } },
{ minX: 10, minY: 0, maxX: 10, maxY: 0, data: { id: '1', loc: [10,0], heading: 90, sequence_id: '100', account_id: '1' } },
{ minX: 10, minY: 1, maxX: 10, maxY: 1, data: { id: '2', loc: [10,1], heading: 90, sequence_id: '100', account_id: '2' } }
];
panoramax.cache().images.rtree.load(features);
panoramax.cache().sequences.lineString['100'] = { rotation: 0, images: [ features[0].data, features[1].data, features[2].data ] };
var res = panoramax.sequences(context.projection, 14);
expect(res).to.deep.eql([{
rotation: 0, images: [features[0].data, features[1].data, features[2].data]
}]);
});
});
describe('#selectedImage', function() {
it('sets and gets selected image', function() {
var d = { id: 'foo', sequence_id: '100'};
panoramax.cache().images = { forImageId: { foo: d }};
panoramax.selectImage(context, 'foo');
expect(panoramax.getActiveImage()).to.eql(d);
});
});
});
+6 -5
View File
@@ -26,7 +26,7 @@ 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(17);
expect(nodes.length).to.eql(18);
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;
@@ -40,10 +40,11 @@ describe('iD.svgLayers', function () {
expect(d3.select(nodes[10]).classed('kartaview')).to.be.true;
expect(d3.select(nodes[11]).classed('mapilio')).to.be.true;
expect(d3.select(nodes[12]).classed('vegbilder')).to.be.true;
expect(d3.select(nodes[13]).classed('local-photos')).to.be.true;
expect(d3.select(nodes[14]).classed('debug')).to.be.true;
expect(d3.select(nodes[15]).classed('geolocate')).to.be.true;
expect(d3.select(nodes[16]).classed('touch')).to.be.true;
expect(d3.select(nodes[13]).classed('panoramax')).to.be.true;
expect(d3.select(nodes[14]).classed('local-photos')).to.be.true;
expect(d3.select(nodes[15]).classed('debug')).to.be.true;
expect(d3.select(nodes[16]).classed('geolocate')).to.be.true;
expect(d3.select(nodes[17]).classed('touch')).to.be.true;
});
});