@panoramax/web-viewer
Version:
Panoramax web viewer for geolocated pictures
406 lines (362 loc) • 12.4 kB
JavaScript
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}
=${() => 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"
=${this._onItemEditClick}
>
${fa(faPen, {transform: {size: 18}})}
</pnx-button>
<pnx-button
kind="superinline"
title=${this._parent?._t.pnx.semantics_delete_annotation}
slot="action"
=${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]}
=${() => this._onListItemClick(e)}
=${() => this._onListItemHover(e)}
=${() => 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);