Files
iD/modules/services/panoramax.js
Martin Raifer 2dc64f0fef cache panoramax sequence data (prev/next links)
to allow more snappy scrolling through the photos of a sequence using the prev/next buttons
the cache is primed with information for "most of" the selected sequence, see also https://gitlab.com/panoramax/server/api/-/issues/268
2025-05-21 16:56:57 +02:00

764 lines
24 KiB
JavaScript

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';
import { services } from './';
const apiUrl = 'https://api.panoramax.xyz/';
const tileUrl = apiUrl + 'api/map/{z}/{x}/{y}.mvt';
const imageDataUrl = apiUrl + 'api/collections/{collectionId}/items/{itemId}';
const sequenceDataUrl = apiUrl + 'api/collections/{collectionId}/items?limit=1000';
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 _currentScene = {
currentImage : null,
nextImage : null,
prevImage : null
};
let _activeImage;
let _isViewerOpen = false;
// 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.
* @param {number} limit Number of maximum objects to return
* @param {*} projection Current projection
* @param {*} rtree The cache
* @returns Data found
*/
function searchLimited(limit, projection, rtree) {
limit = limit || 5;
return partitionViewport(projection)
.reduce(function(result, extent) {
let found = rtree.search(extent.bbox());
const spacing = Math.max(1, Math.floor(found.length / limit));
found = found
.filter((d, idx) => idx % spacing === 0 ||
d.data.id === _activeImage?.id)
.sort((a, b) => {
if (a.data.id === _activeImage?.id) return -1;
if (b.data.id === _activeImage?.id) return 1;
return 0;
})
.slice(0, limit)
.map(d => d.data);
return (found.length ? result.concat(found) : result);
}, []);
}
/**
* Load all data for the specified type from Panoramax vector tiles
* @param {string} which Either 'images' or 'lines'
* @param {string} url Tile endpoint
* @param {number} maxZoom Maximum zoom out
* @param {*} projection Current projection
* @param {number} zoom current zoom
*/
function loadTiles(which, url, maxZoom, projection, zoom) {
const tiler = utilTiler().zoomExtent([minZoom, maxZoom]).skipNullIsland(true);
const tiles = tiler.getTiles(projection);
tiles.forEach(function(tile) {
loadTile(which, url, tile, zoom);
});
}
/**
* Load all data for the specified type from one vector tile
* @param {*} which Either 'images' or 'lines'
* @param {*} url Tile endpoint
* @param {*} tile Current tile
* @param {*} zoom Current zoom
*/
function loadTile(which, url, tile, zoom) {
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, zoom);
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
}
});
}
/**
* Fetches all data for the specified tile and adds them to cache
* @param {*} data Tile data
* @param {*} tile Current tile
* @param {*} zoom Current zoom
*/
function loadTileDataToCache(data, tile, zoom) {
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 = {
service: 'photo',
loc: loc,
capture_time: feature.properties.ts,
capture_time_parsed: new Date(feature.properties.ts),
id: feature.properties.id,
account_id: feature.properties.account_id,
sequence_id: feature.properties.first_sequence,
heading: parseInt(feature.properties.heading, 10),
image_path: '',
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 (cache.rtree) {
cache.rtree.load(features);
}
}
if (vectorTile.layers.hasOwnProperty(sequenceLayer)) {
cache = _cache.sequences;
if (zoom >= lineMinZoom && zoom < imageMinZoom) cache = _cache.mockSequences;
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];
}
}
}
}
/**
* Fetches the username from Panoramax
* @param {string} userId
* @returns the username
*/
async function getUsername(userId) {
const cache = _cache.users;
if (cache[userId]) return cache[userId].name;
const requestUrl = usernameURL.replace('{userId}', userId);
const response = await fetch(requestUrl, { method: 'GET' });
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
const data = await response.json();
cache[userId] = data;
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: {}, items: {} },
users: {},
mockSequences: { rtree: new RBush(), lineString: {} },
requests: { loaded: {}, inflight: {} }
};
},
/**
* Get visible images from cache
* @param {*} projection Current Projection
* @returns images data for the current projection
*/
images: function(projection) {
const limit = 5;
return searchLimited(limit, projection, _cache.images.rtree);
},
/**
* Get a specific image from cache
* @param {*} imageKey the image id
* @returns
*/
cachedImage: function(imageKey) {
return _cache.images.forImageId[imageKey];
},
/**
* Fetches images data for the visible area
* @param {*} projection Current Projection
*/
loadImages: function(projection) {
loadTiles('images', tileUrl, imageMinZoom, projection);
},
/**
* Fetches sequences data for the visible area
* @param {*} projection Current Projection
*/
loadLines: function(projection, zoom) {
loadTiles('line', tileUrl, lineMinZoom, projection, zoom);
},
/**
* Fetches all possible userIDs from Panoramax
* @param {string} usernames one or multiple usernames
* @returns userIDs
*/
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));
},
/**
* Get visible sequences from cache
* @param {*} projection Current Projection
* @param {number} zoom Current zoom (if zoom < `lineMinZoom` less accurate lines will be drawn)
* @returns sequences data for the current projection
*/
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.mockSequences.lineString).forEach(function(sequenceId) {
lineStrings = lineStrings.concat(_cache.mockSequences.lineString[sequenceId]);
});
}
return lineStrings;
},
/**
* Updates the data for the currently visible image
* @param {*} image Image data
*/
setActiveImage: function(image) {
if (image && image.id && image.sequence_id) {
_activeImage = {
id: image.id,
sequence_id: image.sequence_id,
loc: image.loc
};
} else {
_activeImage = null;
}
},
getActiveImage: function(){
return _activeImage;
},
/**
* Update the currently highlighted sequence and selected bubble
* @param {*} context Current HTML context
* @param {*} [hovered] The hovered bubble image
*/
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.sequence_id === selectedSequenceId || 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.properties.id === hoveredSequenceId; })
.classed('currentView', function(d) { return d.properties.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;
},
// Get viewer status
isViewerOpen: function() {
return _isViewerOpen;
},
/**
* Updates the URL to save the current shown image
* @param {*} imageKey
*/
updateUrlImage: function(imageKey) {
const hash = utilStringQs(window.location.hash);
if (imageKey) {
hash.photo = 'panoramax/' + imageKey;
} else {
delete hash.photo;
}
window.history.replaceState(null, '', '#' + utilQsString(hash, true));
},
/**
* Loads the selected image in the frame
* @param {*} context Current HTML context
* @param {*} id of the selected image
* @returns
*/
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');
this.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
.showPhotoFrame(wrap)
.selectPhoto(d, true);
});
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('@' + username);
});
}
return this;
},
photoFrame: function() {
return _currentFrame;
},
/**
* Fetches the data for a specific image
* @param {*} collectionId
* @param {*} imageId
* @returns The fetched image data
*/
getImageData: async function(collectionId, imageId) {
const cache = _cache.sequences.items;
if (cache[collectionId]) {
const cached = cache[collectionId]
.find(d => d.id === imageId);
if (cached) return cached;
} else {
// prime the cache with data from sequence
const response = await fetch(sequenceDataUrl
.replace('{collectionId}', collectionId),
{ method: 'GET' });
if (!response.ok) {
throw new Error(response.status + ' ' + response.statusText);
}
const data = (await response.json()).features;
cache[collectionId] = data;
}
const result = cache[collectionId]
.find(d => d.id === imageId);
if (result) return result;
// not found in sequence: retry to load single item data
// ideally, we'd use the `withPicture` parameter, but it is buggy:
// https://gitlab.com/panoramax/server/api/-/issues/268
const itemResponse = await fetch(imageDataUrl
.replace('{collectionId}', collectionId)
.replace('{itemId}', imageId),
{ method: 'GET' });
if (!itemResponse.ok) {
throw new Error(itemResponse.status + ' ' + itemResponse.statusText);
}
const itemData = await itemResponse.json();
cache[collectionId].push(itemData);
return itemData;
},
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'));
});
/**
* Loads the next image in the sequence
* @param {number} stepBy '-1' if backwards or '1' if forward
* @returns
*/
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];
if (nextImage){
context.map().centerEase(nextImage.loc);
that.selectImage(context, nextImage.id);
}
};
}
return _loadViewerPromise;
},
/**
* Shows the current viewer if hidden
* @param {*} context
*/
showViewer: function (context) {
const wrap = context.container().select('.photoviewer');
const isHidden = wrap.selectAll('.photo-wrapper.panoramax-wrapper.hide').size();
if (isHidden) {
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.panoramax-wrapper')
.classed('hide', false);
}
_isViewerOpen = true;
return this;
},
/**
* Hides the current viewer if shown, resets the active image and sequence
* @param {*} context
*/
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(null);
_isViewerOpen = false;
return this.setStyles(context, null);
},
cache: function() {
return _cache;
}
};