@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
503 lines (456 loc) • 17.1 kB
JavaScript
import ArrowTriangleSVG from "../img/arrow_triangle.svg";
import ArrowTurnSVG from "../img/arrow_turn.svg";
import { svgToPSVLink, COLORS, getDistance, getSimplifiedAngle, getArrow } from "./utils";
const ArrowTriangle = svgToPSVLink(ArrowTriangleSVG, "white");
const ArrowTurn = svgToPSVLink(ArrowTurnSVG, COLORS.NEXT);
/**
* Read float value from EXIF tags (to handle fractions & all)
* @param {*} val The input EXIF tag value
* @returns {number|undefined} The parsed value, or undefined if value is not readable
* @private
*/
export function getExifFloat(val) {
// Null-like values
if(
[null, undefined, ""].includes(val)
|| typeof val === "string" && val.trim() === ""
) {
return undefined;
}
// Already valid number
else if(typeof val === "number") {
return val;
}
// String
else if(typeof val === "string") {
// Check if looks like a fraction
if(/^-?\d+(\.\d+)?\/-?\d+(\.\d+)?$/.test(val)) {
const parts = val.split("/").map(p => parseFloat(p));
return parts[0] / parts[1];
}
// Try a direct cast to float
try { return parseFloat(val); }
catch(e) {} // eslint-disable-line no-empty
// Unrecognized
return undefined;
}
else { return undefined; }
}
/**
* Find in picture metadata the GPS precision.
* @param {object} picture The GeoJSON picture feature
* @returns {string} The precision value (poor, fair, moderate, good, excellent, ideal, unknown)
* @private
*/
export function getGPSPrecision(picture) {
let quality = null;
const gpsHPosError = picture?.properties?.["quality:horizontal_accuracy"] || getExifFloat(picture?.properties?.exif?.["Exif.GPSInfo.GPSHPositioningError"]);
const gpsDop = getExifFloat(picture?.properties?.exif?.["Exif.GPSInfo.GPSDOP"]);
if(gpsHPosError !== undefined) {
quality = `${gpsHPosError} m`;
}
else if(gpsDop !== undefined) {
if(gpsDop < 1) { quality = "ideal"; }
else if(gpsDop < 2) { quality = "excellent"; }
else if(gpsDop < 5) { quality = "good"; }
else if(gpsDop < 10) { quality = "moderate"; }
else if(gpsDop < 20) { quality = "fair"; }
else { quality = "poor"; }
}
return quality;
}
/**
* Compute PSV sphere correction based on picture metadata & EXIF tags.
* @param {object} picture The GeoJSON picture feature
* @returns {object} The PSV sphereCorrection value
* @private
*/
export function getSphereCorrection(picture) {
// Photo direction
let dir = picture.properties?.["view:azimuth"];
if(dir === undefined) {
const v = getExifFloat(picture.properties?.exif?.["Exif.GPSInfo.GPSImgDirection"]);
if(v !== undefined) {
dir = v;
}
}
dir = dir || 0;
// Yaw
let yaw = picture.properties?.["pers:yaw"];
let exifFallbacks = ["Xmp.GPano.PoseHeadingDegrees", "Xmp.Camera.Yaw", "Exif.MpfInfo.MPFYawAngle"];
if(yaw === undefined) {
for(let exif of exifFallbacks) {
const v = getExifFloat(picture.properties?.exif?.[exif]);
if(v !== undefined) {
yaw = v;
break;
}
}
}
yaw = yaw || 0;
// Check if yaw is applicable: different from photo direction
if(Math.round(dir) === Math.round(yaw) && yaw > 0) {
console.warn("Picture with UUID", picture.id, "has same GPS Image direction and Yaw, could cause rendering issues");
// yaw = 0;
}
// Pitch
let pitch = picture.properties?.["pers:pitch"];
exifFallbacks = ["Xmp.GPano.PosePitchDegrees", "Xmp.Camera.Pitch", "Exif.MpfInfo.MPFPitchAngle"];
if(pitch === undefined) {
for(let exif of exifFallbacks) {
const v = getExifFloat(picture.properties?.exif?.[exif]);
if(v !== undefined) {
pitch = v;
break;
}
}
}
pitch = pitch || 0;
// Roll
let roll = picture.properties?.["pers:roll"];
exifFallbacks = ["Xmp.GPano.PoseRollDegrees", "Xmp.Camera.Roll", "Exif.MpfInfo.MPFRollAngle"];
if(roll === undefined) {
for(let exif of exifFallbacks) {
const v = getExifFloat(picture.properties?.exif?.[exif]);
if(v !== undefined) {
roll = v;
break;
}
}
}
roll = roll || 0;
// Send result
return pitch !== 0 && roll !== 0 ? {
pan: yaw * Math.PI / 180,
tilt: pitch * Math.PI / 180,
roll: roll * Math.PI / 180,
} : {};
}
/**
* Compute PSV panoData for cropped panorama based on picture metadata & EXIF tags.
* @param {object} picture The GeoJSON picture feature
* @param {object} [img] The loaded image file, with width, height properties for possible resizing
* @returns {object} The PSV panoData values
* @private
*/
export function getCroppedPanoData(picture, img) {
let res;
if(picture.properties?.["pers:interior_orientation"]) {
if(
picture.properties["pers:interior_orientation"]?.["visible_area"]
&& picture.properties["pers:interior_orientation"]?.["sensor_array_dimensions"]
) {
const va = picture.properties["pers:interior_orientation"]["visible_area"];
const sad = picture.properties["pers:interior_orientation"]["sensor_array_dimensions"];
try {
res = {
fullWidth: parseInt(sad[0]),
fullHeight: parseInt(sad[1]),
croppedX: parseInt(va[0]),
croppedY: parseInt(va[1]),
croppedWidth: parseInt(sad[0]) - parseInt(va[2]) - parseInt(va[0]),
croppedHeight: parseInt(sad[1]) - parseInt(va[3]) - parseInt(va[1]),
};
}
catch(e) {
console.warn("Invalid pers:interior_orientation values for cropped panorama "+picture.id);
}
}
}
if(!res && picture.properties?.exif) {
try {
res = {
fullWidth: parseInt(picture.properties.exif?.["Xmp.GPano.FullPanoWidthPixels"]),
fullHeight: parseInt(picture.properties.exif?.["Xmp.GPano.FullPanoHeightPixels"]),
croppedX: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaLeftPixels"]),
croppedY: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaTopPixels"]),
croppedWidth: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaImageWidthPixels"]),
croppedHeight: parseInt(picture.properties.exif?.["Xmp.GPano.CroppedAreaImageHeightPixels"]),
};
// Fix images with cropped size higher than full size
if(res.croppedWidth > res.fullWidth) { res.fullWidth = res.croppedWidth; }
if(res.croppedHeight > res.fullHeight) { res.fullHeight = res.croppedHeight; }
}
catch(e) {
console.warn("Invalid XMP.GPano values for cropped panorama "+picture.id);
}
}
// Check if crop is really necessary
if(res) {
res = Object.fromEntries(Object.entries(res || {}).filter(e => !isNaN(e[1])));
if(
(!res.fullWidth && !res.croppedWidth && res.fullHeight && !res.croppedHeight)
|| (res.fullWidth && !res.croppedWidth && !res.fullHeight && !res.croppedHeight)
|| (res.fullWidth && !res.croppedWidth && res.fullHeight && !res.croppedHeight)
|| (res.fullWidth == res.croppedWidth && res.fullHeight == res.croppedHeight)
) {
res = {};
}
}
// Resize if image given
if(res?.fullWidth && img?.width) {
let ratio = img.width / (res?.croppedWidth || res.fullWidth);
res = {
fullWidth: res.fullWidth !== undefined ? Math.floor(res.fullWidth * ratio) : undefined,
fullHeight: res.fullHeight !== undefined ? Math.floor(res.fullHeight * ratio) : undefined,
croppedWidth: res.croppedWidth !== undefined ? Math.floor(res.croppedWidth * ratio) : undefined,
croppedHeight: res.croppedHeight !== undefined ? Math.floor(res.croppedHeight * ratio) : undefined,
croppedX: res.croppedX !== undefined ? Math.floor(res.croppedX * ratio) : undefined,
croppedY: res.croppedY !== undefined ? Math.floor(res.croppedY * ratio) : undefined,
};
}
return res || {};
}
/**
* Compare function to retrieve most appropriate picture in a single direction.
*
* @param {number[]} picPos The picture [x,y] position
* @returns {function} A compare function for sorting
* @private
*/
export function sortPicturesInDirection(picPos) {
return (a,b) => {
// Two prev/next links = no sort
if(a.rel != "related" && b.rel != "related") { return 0; }
// First is prev/next link = goes first
else if(a.rel != "related") { return -1; }
// Second is prev/next link = goes first
else if(b.rel != "related") { return 1; }
// Two related links same day = nearest goes first
else if(a.date == b.date) { return getDistance(picPos, a.geometry.coordinates) - getDistance(picPos, b.geometry.coordinates); }
// Two related links at different day = recent goes first
else { return b.date.localeCompare(a.date); }
};
}
/**
* Generates the navbar caption based on a single picture metadata
*
* @param {object} metadata The picture metadata
* @param {object} t The labels translations container
* @returns {object} Normalized object with user name, licence and date
* @private
*/
export function getNodeCaption(metadata, t) {
const caption = {};
// Timestamp
if(metadata?.properties?.datetimetz) {
caption.date = new Date(metadata.properties.datetimetz);
const timeZoneMatch = metadata.properties.datetimetz.match(/([+-]\d{2}):(\d{2})$|Z$/);
if (timeZoneMatch) {
if (timeZoneMatch[0] === "Z") {
caption.tz = "UTC";
} else {
caption.tz = timeZoneMatch[0];
}
}
}
else if(metadata?.properties?.datetime) {
caption.date = new Date(metadata.properties.datetime);
}
// Producer
if(metadata?.providers) {
const producerRoles = metadata?.providers?.filter(el => el?.roles?.includes("producer"));
if(producerRoles?.length >= 0) {
// Avoid duplicates between account name and picture author
const producersDeduped = {};
producerRoles.map(p => p.name).forEach(p => {
const pmin = p.toLowerCase().replace(/\s/g, "");
if(producersDeduped[pmin]) { producersDeduped[pmin].push(p); }
else { producersDeduped[pmin] = [p];}
});
// Keep best looking name for each
caption.producer = [];
Object.values(producersDeduped).forEach(pv => {
const deflt = pv[0];
const better = pv.find(v => v.toLowerCase() != v);
caption.producer.push(better || deflt);
});
}
}
// License
if(metadata?.properties?.license) {
caption.license = metadata.properties.license;
// Look for URL to license
if(metadata?.links) {
const licenseLink = metadata.links.find(l => l?.rel === "license");
if(licenseLink) {
caption.license = `<a href="${licenseLink.href}" title="${t.pnx.metadata_general_license_link}" target="_blank">${caption.license}</a>`;
}
}
}
return caption;
}
/**
* Transforms a GeoJSON feature from the STAC API into a PSV node.
*
* @param {object} f The API GeoJSON feature
* @param {object} t The labels translations container
* @param {boolean} [fastInternet] True if Internet speed is high enough for loading HD flat pictures
* @param {function} [customLinkFilter] A function checking if a STAC link is acceptable to use for picture navigation
* @param {function} [urlCleaner] A function that each URL goes through for cleaning (allows use of relative URLs)
* @return {object} A PSV node
* @private
*/
export function apiFeatureToPSVNode(f, t, fastInternet=false, customLinkFilter=null, urlCleaner=(u => u)) {
const isHorizontalFovDefined = f.properties?.["pers:interior_orientation"]?.["field_of_view"] != null;
let horizontalFov = isHorizontalFovDefined ? parseInt(f.properties["pers:interior_orientation"]["field_of_view"]) : 70;
const is360 = horizontalFov === 360;
const hdUrl = urlCleaner((Object.values(f.assets).find(a => a?.roles?.includes("data")) || {}).href);
const matrix = f?.properties?.["tiles:tile_matrix_sets"]?.geovisio;
const prev = f.links?.find?.(l => l?.rel === "prev" && l?.type === "application/geo+json");
const next = f.links?.find?.(l => l?.rel === "next" && l?.type === "application/geo+json");
const baseUrlWebp = Object.values(f.assets).find(a => a.roles?.includes("visual") && a.type === "image/webp");
const baseUrlJpeg = Object.values(f.assets).find(a => a.roles?.includes("visual") && a.type === "image/jpeg");
const baseUrl = urlCleaner((baseUrlWebp || baseUrlJpeg).href);
const thumbUrl = urlCleaner((Object.values(f.assets).find(a => a.roles?.includes("thumbnail") && a.type === "image/jpeg"))?.href);
const tileUrl = f?.asset_templates?.tiles_webp || f?.asset_templates?.tiles;
const croppedPanoData = getCroppedPanoData(f);
const origInstance = f.links?.find?.(l => l?.rel === "via" && l?.type === "application/json");
const size = f.properties?.["pers:interior_orientation"]?.["sensor_array_dimensions"];
let panorama;
// Cropped panorama
if(Object.keys(croppedPanoData).length > 0) {
panorama = {
baseUrl: fastInternet ? hdUrl : baseUrl,
origBaseUrl: fastInternet ? hdUrl : baseUrl,
hdUrl,
thumbUrl,
basePanoData: fastInternet ? croppedPanoData : (img) => getCroppedPanoData(f, img),
cropData: croppedPanoData,
width: croppedPanoData.fullWidth,
// This is only to mock loading of tiles (which are not available for flat pictures)
cols: 2, rows: 1, tileUrl: () => null
};
}
// 360°
else if(is360 && matrix) {
panorama = {
baseUrl,
origBaseUrl: baseUrl,
basePanoData: (img) => ({
fullWidth: img.width,
fullHeight: img.height,
}),
hdUrl,
thumbUrl,
cols: matrix && matrix.tileMatrix[0].matrixWidth,
rows: matrix && matrix.tileMatrix[0].matrixHeight,
width: matrix && (matrix.tileMatrix[0].matrixWidth * matrix.tileMatrix[0].tileWidth),
tileUrl: matrix && ((col, row) => urlCleaner(tileUrl.href.replace(/\{TileCol\}/g, col).replace(/\{TileRow\}/g, row)))
};
}
// Flat pictures: shown only using a cropped base panorama
else {
const getFlatCrop = (img) => {
if (img.width < img.height && !isHorizontalFovDefined) {
horizontalFov = 35;
}
const verticalFov = horizontalFov * img.height / img.width;
const panoWidth = img.width * 360 / horizontalFov;
const panoHeight = img.height * 180 / verticalFov;
return {
fullWidth: Math.floor(panoWidth),
fullHeight: Math.floor(panoHeight),
croppedWidth: img.width,
croppedHeight: img.height,
croppedX: Math.floor((panoWidth - img.width) / 2),
croppedY: Math.floor((panoHeight - img.height) / 2),
};
};
let cropData = size ? getFlatCrop({width: size[0], height: size[1]}) : null;
panorama = {
baseUrl: fastInternet ? hdUrl : baseUrl,
origBaseUrl: fastInternet ? hdUrl : baseUrl,
hdUrl,
thumbUrl,
basePanoData: getFlatCrop,
cropData,
width: cropData?.fullWidth || 2,
// This is only to mock loading of tiles (which are not available for flat pictures)
cols: 2, rows: 1, tileUrl: () => null
};
}
// Cleanup empty semantics feature (metacatalog bug)
if(f.properties?.semantics) {
f.properties.semantics = f.properties.semantics.filter(o => Object.keys(o).length > 0);
}
const node = {
id: f.id,
caption: getNodeCaption(f, t),
panorama,
links: filterRelatedPicsLinks(f, customLinkFilter),
gps: f.geometry.coordinates,
sequence: {
id: f.collection,
nextPic: next ? next.id : undefined,
prevPic: prev ? prev.id : undefined
},
sphereCorrection: getSphereCorrection(f),
horizontalFov,
origInstance,
properties: f.properties,
};
return node;
}
/**
* Filter surrounding pictures links to avoid too much arrows on viewer.
* @private
*/
export function filterRelatedPicsLinks(metadata, customFilter = null) {
const picLinks = metadata.links
.filter(l => ["next", "prev", "related"].includes(l?.rel) && l?.type === "application/geo+json")
.filter(l => customFilter ? customFilter(l) : true)
.map(l => {
if(l.datetime) {
l.date = l.datetime.split("T")[0];
}
return l;
});
const picPos = metadata.geometry.coordinates;
// Filter to keep a single link per direction, in same sequence or most recent one
const filteredLinks = [];
const picSurroundings = { "N": [], "ENE": [], "ESE": [], "S": [], "WSW": [], "WNW": [] };
for(let picLink of picLinks) {
const a = getSimplifiedAngle(picPos, picLink.geometry.coordinates);
picSurroundings[a].push(picLink);
}
for(let direction in picSurroundings) {
const picsInDirection = picSurroundings[direction];
if(picsInDirection.length == 0) { continue; }
picsInDirection.sort(sortPicturesInDirection(picPos));
filteredLinks.push(picsInDirection.shift());
}
let arrowStyle = l => l.rel === "related" ? {
element: getArrow(ArrowTurn),
size: { width: 64*2/3, height: 192*2/3 }
} : {
element: getArrow(ArrowTriangle),
size: { width: 75, height: 75 }
};
const rectifiedYaw = - (metadata.properties?.["view:azimuth"] || 0) * (Math.PI / 180);
return filteredLinks.map(l => ({
nodeId: l.id,
gps: l.geometry.coordinates,
arrowStyle: arrowStyle(l),
linkOffset: { yaw: rectifiedYaw }
}));
}
/**
* Read structured hashtags from picture metadata
* @private
*/
export function getHashTags(metadata) {
if(!metadata?.properties?.semantics) { return []; }
// Find hashtag entry in semantics
const hashtagsTags = metadata.properties.semantics.filter(kv => kv.key === "hashtags");
const readHashTags = [];
hashtagsTags.forEach(htt => htt.value.split(";").forEach(v => readHashTags.push(v.trim())));
return readHashTags;
}
/**
* Check if a given picture has associated annotations.
* @private
*/
export function hasAnnotations(metadata) {
return metadata?.properties?.annotations?.length > 0;
}