UNPKG

@panoramax/web-viewer

Version:

Panoramax web viewer for geolocated pictures

406 lines (362 loc) 12.4 kB
import { LitElement, html, css, nothing } from "lit"; import { faSvg, iconify, badgeCount } from "../styles"; import { fa, moreIcons } from "../../utils/widgets"; import { faChevronRight } from "@fortawesome/free-solid-svg-icons/faChevronRight"; import { faPen } from "@fortawesome/free-solid-svg-icons/faPen"; import { faTrash } from "@fortawesome/free-solid-svg-icons/faTrash"; import { faArrowLeft } from "@fortawesome/free-solid-svg-icons/faArrowLeft"; import { faImages } from "@fortawesome/free-solid-svg-icons/faImages"; import { faVectorSquare } from "@fortawesome/free-solid-svg-icons/faVectorSquare"; import { onceParentAvailable } from "../../utils/widgets"; import { groupByPrefix } from "../../utils/semantics"; import "iconify-icon"; /** * Semantics list shows listing of both picture tags and annotations. * It uses the parent component currently selected picture. * * @class Panoramax.components.menus.SemanticsList * @element pnx-semantics-list * @extends [lit.LitElement](https://lit.dev/docs/api/LitElement/) * @fires Panoramax.components.menus.SemanticsList#select * @fires Panoramax.components.menus.SemanticsList#edit-click * @fires Panoramax.components.menus.SemanticsList#delete-click * @example * ```html * <pnx-semantics-list _parent=${viewer} /> * ``` */ export default class SemanticsList extends LitElement { /** @private */ static styles = [ faSvg, iconify, badgeCount, css` .annotation { background: var(--white); } pnx-list-item { display: block; margin: 5px 0; } ` ]; /** * Component properties. * @memberof Panoramax.components.menus.SemanticsList# * @type {Object} * @property {boolean} [editable=false] Show an "Edit" button ? */ static properties = { editable: {type: Boolean}, _meta: {state: true}, _seqMeta: {state: true}, _entries: {state: true}, _selectedEntry: {state: true}, _annotPresets: {state: true}, _picPresets: {state: true}, _seqPresets: {state: true}, }; constructor() { super(); this._meta = {}; this._seqMeta = {}; this._selectedEntry = null; this._annotPresets = {}; this._picPresets = []; this._seqPresets = []; this._entries = null; this.editable = false; moreIcons(); } /** @private */ connectedCallback() { super.connectedCallback(); this._onPicChange(); onceParentAvailable(this).then(() => { this._parent.psv?.addEventListener("picture-loaded", this._onPicChange.bind(this)); this._parent.psv?.addEventListener("annotation-focused", e => this.showAnnotation(e.detail.annotationId)); this._parent.psv?.addEventListener("annotations-unfocused", () => this._onListItemClick(null)); this._parent.psv?.addEventListener("semantics-unfocused", () => this._onListItemClick(null)); const selectedAnnots = this._parent.psv.getSelectedAnnotations(); if(selectedAnnots.length > 0) { this.showAnnotation(selectedAnnots[0]); } }); } /** * Display a specific annotation details. * @param {string} id The annotation UUID * @memberof Panoramax.components.menus.SemanticsList# */ showAnnotation(id) { if(!this._entries) { setTimeout(() => this.showAnnotation(id), 200); } else { this._onListItemClick(this._entries.find(e => e.type === "annotation" && e.annotation.id == id)); } } /** * Display a specific tag group details. * @param {object} tags The tags to find * @memberof Panoramax.components.menus.SemanticsList# */ showTagsGroup(tags) { if(!this._entries) { setTimeout(() => this.showTagsGroup(tags), 200); } else { this._onListItemClick(this._entries.find(e => { if(e?.type === "annotation") { return false; } for(let i=0; i < tags.length; i++) { if(!e.tagGroup.semantics.find(s => tags[i].key === s.key && tags[i].value === s.value)) { return false; } } return true; })); } } /** @private */ _onPicChange() { this._meta = this._parent?.psv?.getPictureMetadata(); this._getSeqMeta(); delete this._prevPsvView; this._selectedEntry = null; this._entries = null; this.dispatchEvent(new CustomEvent("select", { detail: { item: null } })); // Load presets if(this._meta && this._parent?.presetsManager) { this._annotPresets = {}; this._picPresets = []; // Picture tags this._parent.presetsManager.getPresets(this._meta.properties).then(p => { this._picPresets = p; this._computeEntries(); }); // Annotations if(this._meta.properties?.annotations?.length > 0) { Promise.all(this._meta.properties.annotations.map(a => this._parent.presetsManager.getPreset(a).then(p => { this._annotPresets[a.id] = p; }))).then(() => this._computeEntries()); } } } /** @private */ async _getSeqMeta() { const seqId = this._meta?.sequence?.id; // If no cache, call API if(seqId && !this._parent._sequencesMetadata[seqId]) { this._parent._sequencesMetadata[seqId] = await this._parent.api.getSequenceMetadata(seqId); } this._seqMeta = this._parent._sequencesMetadata[seqId]; this._seqPresets = []; if(this._seqMeta?.semantics?.length > 0) { const p = await this._parent.presetsManager.getPresets(this._seqMeta); this._seqPresets = p; this._computeEntries(); } } /** @private */ _getGroupedTags(semantics, type, unknownTagId, presets) { const entries = []; const groupToList = (t, pkey) => { const g = [ {key: pkey, value: t.value} ]; if(t.qualifiers) { t.qualifiers.forEach(q => g.push({key: q.key, value: q.value})); } return g; }; const tagsGroups = groupByPrefix(semantics); const nonMatchingTags = []; tagsGroups.forEach(g => { const usedTags = {}; g.tags.forEach(t => { const pkey = g.prefix != "" ? `${g.prefix}|${t.key}` : t.key; // Skip if tag found in a previous preset if(usedTags[`${pkey}=${t.value}`]) { groupToList(t, pkey).forEach(v => usedTags[`${pkey}=${t.value}`].push(v)); return; } // Check if a picture preset matches this tag const preset = presets.find(p => ["*", t.value].includes(p.tags[pkey])); if(preset) { const groupTags = groupToList(t, pkey); // Add all used tags for this preset into used ones let usedTagsCount = 0; Object.entries(preset.tags).forEach(([prk, prv]) => { let utv = prv; if(prv === "*") { utv = this._meta.properties.semantics.find(s => s.key === pkey)?.value; } usedTags[prk+"="+utv] = groupTags; usedTagsCount++; }); entries.push({ title: preset.name, icon: preset.iconify || "fa6-solid:cube", associatedTags: (t.qualifiers?.length || 0) + usedTagsCount, tagGroup: { semantics: groupTags }, type: type, }); } else { nonMatchingTags.push({key: pkey, value: t.value}); } }); }); if(nonMatchingTags.length > 0) { entries.push({ title: this._parent?._t.pnx.semantics_features_default_title.replace("{nb}", unknownTagId++), icon: "fa6-solid:tag", associatedTags: nonMatchingTags.length, tagGroup: { semantics: nonMatchingTags }, type: type, }); } return [ entries, unknownTagId ]; } /** @private */ _computeEntries() { let unknownTagId = 1; let entries = []; let entries2 = []; // Sequence tags if(this._seqMeta?.semantics?.length > 0) { [ entries, unknownTagId ] = this._getGroupedTags(this._seqMeta.semantics, "seqtags", unknownTagId, this._seqPresets); } // Picture tags [ entries2, unknownTagId ] = this._getGroupedTags(this._meta.properties?.semantics, "pictags", unknownTagId, this._picPresets); entries = entries.concat(entries2); // Annotations entries = entries.concat(this._meta.properties?.annotations?.map(a => ({ title: this._annotPresets[a.id]?.name || this._parent?._t.pnx.semantics_features_default_title.replace("{nb}", unknownTagId++), icon: this._annotPresets[a.id]?.iconify || "fa6-solid:cube", associatedTags: a.semantics.filter(s => s.action !== "delete").length, type: "annotation", annotation: a, })) || []); this._entries = entries; } /** @private */ _onListItemHover(item) { if(item?.type === "annotation") { // Save position before hover to allow reset after if(!this._prevPsvView) { this._prevPsvView = [this._parent.psv.getZoomLevel(), this._parent.psv.getPosition()]; } this._parent.psv.focusOnAnnotation(item.annotation.id, undefined, true); } else { this._parent.psv.unfocusAnnotation(); // Restore previous PSV position if(this._prevPsvView) { this._parent.psv.zoom(this._prevPsvView[0]); this._parent.psv.rotate(this._prevPsvView[1]); delete this._prevPsvView; } } } /** @private */ _onListItemClick(item) { this._selectedEntry = item; this._onListItemHover(item); /** * Event for item selection * * @event Panoramax.components.menus.SemanticsList#select * @type {CustomEvent} * @property {object} detail.item The selected annotation/tag group (or null if none) */ this.dispatchEvent(new CustomEvent("select", { detail: { item } })); } /** @private */ _onItemEditClick(e) { e.stopPropagation(); /** * Event for edit button click * * @event Panoramax.components.menus.SemanticsList#edit-click * @type {CustomEvent} * @property {object} detail.item The annotation/tag group to edit */ this.dispatchEvent(new CustomEvent("edit-click", { detail: { item: this._selectedEntry } })); } /** @private */ _onItemDeleteClick(e) { e.stopPropagation(); /** * Event for delete button click * * @event Panoramax.components.menus.SemanticsList#delete-click * @type {CustomEvent} * @property {object} detail.item The annotation/tag group to delete */ this.dispatchEvent(new CustomEvent("delete-click", { detail: { item: this._selectedEntry } })); } /** @private */ render() { /* eslint-disable indent */ if(!this._meta) { return nothing; } // Selected entry if(this._selectedEntry) { return html`<div class="annotation"> <pnx-list-item title=${this._selectedEntry.title} @click=${() => this._onListItemClick(null)} > ${fa(faArrowLeft, {transform: {size: 24}, attributes: {slot: "icon"}})} <iconify-icon slot="icon" icon=${this._selectedEntry.icon} style="font-size: 1.5em" ></iconify-icon> ${this.editable && this._selectedEntry.type !== "seqtags" ? html` <pnx-button kind="superinline" title=${this._parent?._t.pnx.semantics_edit} slot="action" @click=${this._onItemEditClick} > ${fa(faPen, {transform: {size: 18}})} </pnx-button> <pnx-button kind="superinline" title=${this._parent?._t.pnx.semantics_delete_annotation} slot="action" @click=${this._onItemDeleteClick} > ${fa(faTrash, {transform: {size: 18}})} </pnx-button>` : nothing} </pnx-list-item> <pnx-semantics-table ._t=${this._parent?._t} .source=${this._selectedEntry?.type === "annotation" ? this._selectedEntry.annotation : this._selectedEntry.tagGroup} /> </div>`; } // General listing else { const titles = { "seqtags": this._parent?._t.pnx.semantics_sequence_tags, "pictags": this._parent?._t.pnx.semantics_picture_tags, "annotation": this._parent?._t.pnx.semantics_annotation_tags }; return html`<div class="list"> ${(this._entries || []).map(e => html` <pnx-list-item title=${e.title} tooltip=${titles[e.type]} @click=${() => this._onListItemClick(e)} @mouseover=${() => this._onListItemHover(e)} @mouseout=${() => this._onListItemHover(null)} > <iconify-icon slot="icon" icon=${e.icon} style="font-size: 1.5em" ></iconify-icon> ${e.associatedTags > 1 ? html` <span slot="icon" class="pnx-badge-count">${e.associatedTags}</span> ` : nothing} ${e?.type === "annotation" ? fa(faVectorSquare, {transform: {size: 20}, styles: { "margin-right": "20px" }, attributes: {slot: "action"}}) : nothing} ${e?.type === "seqtags" ? fa(faImages, {transform: {size: 20}, styles: { "margin-right": "20px" }, attributes: {slot: "action"}}) : nothing} ${fa(faChevronRight, {transform: {size: 20}, attributes: {slot: "action"}})} </pnx-list-item> `)} </div>`; } } } customElements.define("pnx-semantics-list", SemanticsList);