UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

503 lines (456 loc) 17.1 kB
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; }