@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
443 lines (411 loc) • 14.8 kB
JavaScript
import { LitElement, nothing, css } from "lit";
import { html, unsafeStatic } from "lit/static-html.js";
import { fa, onceParentAvailable } from "../../utils/widgets";
import { faLocationDot } from "@fortawesome/free-solid-svg-icons/faLocationDot";
import { faMedal } from "@fortawesome/free-solid-svg-icons/faMedal";
import { faCamera } from "@fortawesome/free-solid-svg-icons/faCamera";
import { faImage } from "@fortawesome/free-solid-svg-icons/faImage";
import { faImages } from "@fortawesome/free-solid-svg-icons/faImages";
import { faScroll } from "@fortawesome/free-solid-svg-icons/faScroll";
import { faQuestion } from "@fortawesome/free-solid-svg-icons/faQuestion";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons/faInfoCircle";
import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
import { faChevronUp } from "@fortawesome/free-solid-svg-icons/faChevronUp";
import { faTags } from "@fortawesome/free-solid-svg-icons/faTags";
import { faSvg, titles, textarea, hidden } from "../styles";
import { createWebComp } from "../../utils/widgets";
import { getGPSPrecision, getHashTags } from "../../utils/picture";
import { PanoramaxMetaCatalogURL } from "../../utils/services";
import {
getGrade, QUALITYSCORE_GPS_VALUES, QUALITYSCORE_RES_360_VALUES,
QUALITYSCORE_RES_FLAT_VALUES, QUALITYSCORE_POND_GPS, QUALITYSCORE_POND_RES
} from "../../utils/utils";
const missing = () => fa(faQuestion, {styles: {height: "16px"}});
/**
* Picture metadata displays detailed info about a single picture (ID, capture context, EXIF attributes...).
* @class Panoramax.components.menus.PictureMetadata
* @element pnx-picture-metadata
* @extends [lit.LitElement](https://lit.dev/docs/api/LitElement/)
* @example
* ```html
* <pnx-picture-metadata ._parent=${viewer} />
* ```
*/
export default class PictureMetadata extends LitElement {
/** @private */
static styles = [ faSvg, titles, textarea, hidden, css`
div[slot="content"] {
padding: 5px 10px;
background-color: #ededed;
}
/* Small data blocks */
.data-block {
display: inline-block;
min-width: 50%;
margin: 8px 0;
box-sizing: border-box;
vertical-align: top;
}
.data-block h5 {
font-size: 0.8em;
font-weight: 400;
color: var(--blue-dark);
margin: 0 0 5px 0;
}
.data-block h5 pnx-button { vertical-align: middle; }
.data-block h5 svg.svg-inline--fa { height: 14px; }
.data-block div {
font-size: 0.8em;
}
pnx-semantics-table {
overflow-x: auto;
display: block;
}
` ];
/** @private */
static properties = {
_meta: {state: true},
_semanticsPicShowAll: {state: true},
};
constructor() {
super();
this._semanticsPicShowAll = false;
}
/** @private */
connectedCallback() {
super.connectedCallback();
onceParentAvailable(this).then(() => {
this._meta = this._parent?.psv?.getPictureMetadata();
this._parent?.oncePSVReady?.().then(() => {
this._parent.psv.addEventListener("picture-loaded", () => {
this._meta = this._parent.psv.getPictureMetadata();
});
this._parent.psv.addEventListener("annotation-click", () => {
const tabs = this.shadowRoot.querySelector("pnx-tabs");
if(tabs) { tabs.setAttribute("activeTabIndex", 4); }
});
});
});
}
/** @private */
_onQualityScoreClick() {
const qsTab = this.shadowRoot.querySelector("pnx-tabs");
if(qsTab) {
qsTab.setAttribute("activeTabIndex", 3);
}
}
/** @private */
_onCaptureDateClick() {
const qsTab = this.shadowRoot.querySelector("pnx-tabs");
if(qsTab) {
qsTab.setAttribute("activeTabIndex", 1);
}
}
/** @private */
_toTab(title, data) {
return html`
<h4 slot="title">${title}</h4>
<div slot="content" class="data-blocks">
${data.filter(b => b).map(b => html`<div class="data-block" style=${b.style}>
<h5>${b.title}</h5>
<div style=${b.content_style} @click=${b.click}>${b.content}</div>
</div>`)}
</div>
`;
}
/** @private */
render() {
/* eslint-disable indent */
if(!this._meta) { return nothing; }
const lang = this._parent?.lang || window.navigator.language;
// Generic information
const persOrient = this._meta?.properties?.["pers:interior_orientation"];
const makeModel = [persOrient.camera_manufacturer, persOrient.camera_model].filter(v => v).join(" ");
const focal = persOrient?.focal_length ? `${persOrient?.focal_length} mm` : missing();
let resmp = persOrient?.["sensor_array_dimensions"];
if(resmp) { resmp = `${resmp[0]} x ${resmp[1]} px (${Math.floor(resmp[0] * resmp[1] / 1000000)} Mpx)`;}
let pictype = this._parent?._t.pnx.picture_flat;
let pictypelong = this._parent?._t.pnx.picture_flat_long;
let picFov = persOrient?.["field_of_view"]; // Use raw value instead of horizontalFov to avoid default showing up
if(picFov !== null && picFov !== undefined) {
if(picFov === 360) {
pictype = this._parent?._t.pnx.picture_360;
pictypelong = this._parent?._t.pnx.picture_360_long;
}
else { pictype += ` (${picFov}°)`; }
}
// Camera tab
const cameraData = [
{ title: this._parent?._t.pnx.metadata_camera_make, content: persOrient?.camera_manufacturer || missing() },
{ title: this._parent?._t.pnx.metadata_camera_model, content: persOrient?.camera_model || missing() },
{ title: this._parent?._t.pnx.metadata_camera_type, content: pictype },
{ title: this._parent?._t.pnx.metadata_camera_resolution, content: resmp || missing() },
{ title: this._parent?._t.pnx.metadata_camera_focal_length, content: focal },
// Capture date
this._meta?.caption?.date && {
title: this._parent?._t.pnx.metadata_general_date,
content: html`
<strong>${new Intl.DateTimeFormat(lang, {
timeZone: this._meta.caption.tz,
dateStyle: "short"
}).format(this._meta.caption.date)}</strong>
<br />${new Intl.DateTimeFormat(lang, {
timeZone: this._meta.caption.tz,
hour: "numeric",
minute: "numeric",
second: "numeric",
fractionalSecondDigits: 3,
timeZoneName: "longOffset"
}).format(this._meta.caption.date)}
`
}
];
// Location tab
const orientation = this._meta?.properties?.["view:azimuth"] !== undefined && `${this._meta.properties["view:azimuth"]}°`;
const locationData = [
{
title: this._parent?._t.pnx.metadata_location_coordinates,
content: html`
${this._meta.gps[0]}, ${this._meta.gps[1]}
<pnx-copy-coordinates
._parent=${this._parent}
.gps=${this._meta.gps}
style="margin-left: 10px"
></pnx-copy-coordinates>
`,
style: "width: 100%"
},
{ title: this._parent?._t.pnx.metadata_location_orientation, content: orientation || missing() },
{ title: this._parent?._t.pnx.metadata_location_precision, content: getGPSPrecision(this._meta) || missing() },
];
// Quality tab
const hasQualityScore = (
this._parent?.map?._hasQualityScore?.()
|| this._meta?.properties?.["quality:horizontal_accuracy"]
|| this._meta?.properties?.["panoramax:horizontal_pixel_density"]
);
let qualityData, generalGrade;
if(hasQualityScore) {
const gpsGrade = getGrade(QUALITYSCORE_GPS_VALUES, this._meta?.properties?.["quality:horizontal_accuracy"]);
const resGrade = getGrade(
this._meta?.horizontalFov === 360 ? QUALITYSCORE_RES_360_VALUES : QUALITYSCORE_RES_FLAT_VALUES,
this._meta?.properties?.["panoramax:horizontal_pixel_density"]
);
// Note: score is also calculated in utils/map code
generalGrade = Math.round((resGrade || 1) * QUALITYSCORE_POND_RES + (gpsGrade || 1) * QUALITYSCORE_POND_GPS);
qualityData = [
{
title: html`
${this._parent?._t.pnx.metadata_quality_score}
<pnx-button
title="${this._parent?._t.pnx.metadata_quality_help}"
kind="superinline"
@click=${() => this._parent?._showQualityScoreDoc()}
>
${fa(faInfoCircle)}
</pnx-button>
`,
content: html`<pnx-quality-score grade=${generalGrade} style="font-size: 16px"></pnx-quality-score>`,
style: "width: 100%"
},
{
title: this._parent?._t.pnx.metadata_quality_gps_score,
content: createWebComp("pnx-grade", { stars: gpsGrade, _t: this._parent?._t })
},
{
title: this._parent?._t.pnx.metadata_quality_resolution_score,
content: createWebComp("pnx-grade", { stars: resGrade, _t: this._parent?._t })
},
];
}
// Semantics data
const hasSemantics = (
(this._meta.properties.semantics || []).length > 0
|| (this._meta.properties.annotations || []).length > 0
);
let semanticsData = [];
if(hasSemantics) {
// Hashtags
const hashtags = getHashTags(this._meta);
if(hashtags.length > 0) {
semanticsData.push({
title: this._parent?._t.pnx.semantics_hashtags,
style: "width: 100%",
content: hashtags.join(" ")
});
}
// Full list of picture tags
semanticsData.push({
title: this._parent?._t.pnx.semantics_tags_picture,
style: "width: 100%",
content: html`${this._meta.properties.semantics?.length > 0
? html`
<pnx-button
kind="outline"
size="sm"
style="width: 100%"
@click=${() => this._semanticsPicShowAll = !this._semanticsPicShowAll}
>
${this._semanticsPicShowAll ? fa(faChevronUp) : fa(faChevronDown)}
${this._semanticsPicShowAll ? this._parent?._t.pnx.semantics_hide_all_tags : this._parent?._t.pnx.semantics_show_all_tags}
</pnx-button>
<pnx-semantics-table
._t=${this._parent?._t}
.source=${this._meta.properties}
style="margin-top: 5px"
class=${this._semanticsPicShowAll ? "":"pnx-hidden"}
/>
`
: this._parent?._t.pnx.semantics_tags_picture_none
}`
});
// Annotations (features in picture)
semanticsData.push({
title: this._parent?._t.pnx.semantics_features,
style: "width: 100%",
content: html`
${this._meta.properties.annotations?.length > 0
? html`<pnx-annotations-list ._parent=${this._parent} />`
: this._parent?._t.pnx.semantics_features_none}
`
});
}
// EXIF data
const exifData = Object.entries(this._meta.properties.exif)
.sort()
.filter(([, value]) => value)
.map(([key, value]) => {
if(JSON.stringify(value).includes("\\u")) {
value = JSON.stringify(value).replace(/\\u[0-9A-Fa-f]{4}/g, unicode => (
" 0x" + parseInt(unicode.slice(2), 16).toString(16).toUpperCase().padStart(4, "0")
)).slice(1, -1).trim();
}
return {
title: key,
content: value.length > 30 ? html`<textarea readonly .value=${value}></textarea>`: value,
style: value.length > 30 ? "width: 100%" : undefined
};
});
exifData.unshift({
content: html`
<pnx-link-button
size="sm"
target="_blank"
href="https://exiv2.org/metadata.html"
title=${this._parent?._t.pnx.metadata_exif_doc_title}
>${fa(faInfoCircle)} ${this._parent?._t.pnx.metadata_exif_doc}</pnx-link-button>
`,
style: "width: 100%"
});
// General metadata
const overview = [
// Producer
this._meta?.caption?.producer?.length > 0 && {
title: this._parent?._t.pnx.metadata_general_author,
content: html`
<strong>${this._meta.caption.producer[this._meta.caption.producer.length - 1]}</strong>
${this._meta.caption.producer.length > 1 ? html`<br />${this._meta.caption.producer.slice(0, -1).join(", ")}` : nothing}
`
},
// Capture date
this._meta?.caption?.date && {
title: this._parent?._t.pnx.metadata_general_date,
click: this._onCaptureDateClick,
content_style: "cursor: pointer",
content: html`
<strong>${new Intl.DateTimeFormat(lang, {timeZone: this._meta.caption.tz, dateStyle: "long"}).format(this._meta.caption.date)}</strong>
<br />${new Intl.DateTimeFormat(lang, {timeZone: this._meta.caption.tz, hour: "numeric",minute:"numeric", second: "numeric"}).format(this._meta.caption.date)}
`
},
// Camera
persOrient && {
title: this._parent?._t.pnx.metadata_camera,
content: html`
<strong>${makeModel.length > 0 ? makeModel : missing()}</strong>
<br />${pictypelong}
`
},
// License
this._meta?.caption?.license && {
title: this._parent?._t.pnx.metadata_general_license,
content: html`${unsafeStatic(this._meta.caption.license)}`
},
// Quality score
hasQualityScore && {
title: this._parent?._t.pnx.metadata_quality,
click: this._onQualityScoreClick,
content: html`<pnx-quality-score
grade=${generalGrade}
style="font-size: 14px; cursor: pointer"
/>`
},
// Copy ID
{
title: this._parent?._t.pnx.metadata_general_copy_id,
content_style: "display: flex; gap: 5px;",
content: html`
<pnx-copy-button
kind="outline"
size="sm"
._t=${this._parent?._t}
text=${this._meta.id}
style="flex: 1"
>
${fa(faImage)} ${this._parent?._t.pnx.metadata_general_picid}
</pnx-copy-button>
<pnx-copy-button
kind="outline"
size="sm"
._t=${this._parent?._t}
text=${this._meta.sequence.id}
style="flex: 1"
>
${fa(faImages)} ${this._parent?._t.pnx.metadata_general_seqid}
</pnx-copy-button>
`
},
this._meta?.origInstance && {
title: this._parent?._t.pnx.metadata_general_instance,
content: html`<strong>
<a href=${this._meta.origInstance.href+window.location.search} target="_blank" style="text-decoration: none">
<img
src=${PanoramaxMetaCatalogURL()+"/api/instances/"+this._meta.origInstance.instance_name+"/logo"}
style="height: 30px; max-width: 150px; vertical-align: middle"
alt=""
/>
<span style="text-decoration: underline">
${this._meta.origInstance.instance_name || this._meta.origInstance.href.replace(/^http.?:\/\//, "")}
</span>
</a>
</strong>`
}
];
return html`<pnx-tabs>
${this._toTab( // General
html`${fa(faImage)} ${this._parent?._t.pnx.metadata_summary}`,
overview
)}
${this._toTab( // Camera
html`${fa(faCamera)} ${this._parent?._t.pnx.metadata_camera}`,
cameraData
)}
${this._toTab( // Position
html`${fa(faLocationDot)} ${this._parent?._t.pnx.metadata_location}`,
locationData
)}
${hasQualityScore ? this._toTab( // Quality
html`${fa(faMedal)} ${this._parent?._t.pnx.metadata_quality}`,
qualityData
) : nothing}
${hasSemantics ? this._toTab( // Semantics
html`${fa(faTags)} ${this._parent?._t.pnx.semantics_title}`,
semanticsData
) : nothing}
${this._meta.properties?.exif ? this._toTab( // EXIF
html`${fa(faScroll)} ${this._parent?._t.pnx.metadata_exif}`,
exifData
) : nothing}
</pnx-tabs>`;
}
}
customElements.define("pnx-picture-metadata", PictureMetadata);