import { Annotation } from './Annotation.js';
import { Layer } from './Layer.js'
import { addSignals } from './Signals.js';
/**
* @typedef {Object} LayerAnnotationOptions
* @property {string} [style] - CSS styles for annotation rendering
* @property {string|Annotation[]} [annotations=[]] - URL of JSON annotation data or array of annotations
* @property {boolean} [overlay=true] - Whether annotations render as overlay
* @property {Set<string>} [selected=new Set()] - Set of selected annotation IDs
* @property {Object} [annotationsListEntry=null] - UI entry for annotations list
* @extends LayerOptions
*/
/**
* LayerAnnotation provides functionality for displaying and managing annotations overlaid on other layers.
* It supports both local and remote annotation data, selection management, and UI integration.
*
* Features:
* - Display of text, graphics, and glyph annotations
* - Remote annotation loading via JSON/HTTP
* - Selection management
* - Visibility toggling per annotation
* - UI integration with annotation list
* - Annotation event handling
*
* The layer automatically handles:
* - Annotation data loading and parsing
* - UI synchronization
* - Visibility states
* - Selection states
* - Event propagation
*
* @extends Layer
* @fires LayerAnnotation#selected - Fired when annotation selection changes, with selected annotation as parameter
* @fires LayerAnnotation#loaded - Fired when annotations are loaded
* @fires Layer#update - Inherited from Layer, fired when redraw needed
* @fires Layer#ready - Inherited from Layer, fired when layer is ready
*
* @example
* ```javascript
* // Create annotation layer from remote JSON
* const annoLayer = new OpenLIME.LayerAnnotation({
* annotations: 'https://example.com/annotations.json',
* style: '.annotation { color: red; }',
* overlay: true
* });
*
* // Listen for selection changes
* annoLayer.addEvent('selected', (annotation) => {
* console.log('Selected annotation:', annotation.label);
* });
*
* // Add to viewer
* viewer.addLayer('annotations', annoLayer);
* ```
*/
class LayerAnnotation extends Layer { //FIXME CustomData Object template {name: { label: defaultValue: type:number,enum,string,boolean min: max: enum:[] }}
/**
* Instantiates a LayerAnnotation object.
* @param {Object} [options] An object literal with options that inherits from {@link Layer}.
* @param {string} options.style Properties to style annotations.
* @param {(string|Array)} options.annotations The URL of the annotation data (JSON file or HTTP GET Request to an annotation server) or an array of annotations.
*/
constructor(options) {
options = Object.assign({
// geometry: null, //unused, might want to store here the quads/shapes for opengl rendering
style: null, //straightforward for svg annotations, to be defined or opengl rendering
annotations: [],
selected: new Set,
overlay: true,
annotationsListEntry: null, //TODO: horrible name for the interface list of annotations
}, options);
super(options);
if (typeof (this.annotations) == "string") { //assumes it is an URL
(async () => { await this.loadAnnotations(this.annotations); })();
}
}
/**
* Helper method to get idx from annotation data
* @param {Annotation} annotation - The annotation object
* @returns {number|string|null} The idx value from data.idx
* @private
*/
getAnnotationIdx(annotation) {
return annotation.data && annotation.data.idx !== undefined ? annotation.data.idx : null;
}
/**
* Helper method to set idx in annotation data
* @param {Annotation} annotation - The annotation object
* @param {number|string} idx - The idx value to set
* @private
*/
setAnnotationIdx(annotation, idx) {
if (!annotation.data) {
annotation.data = {};
}
annotation.data.idx = idx;
}
/**
* Loads annotations from a URL
* @param {string} url - URL to fetch annotations from (JSON format)
* @fires LayerAnnotation#loaded
* @fires Layer#update
* @fires Layer#ready
* @private
* @async
*/
async loadAnnotations(url) {
const headers = new Headers();
headers.append('pragma', 'no-cache');
headers.append('cache-control', 'no-cache');
var response = await fetch(url, {
method: 'GET',
headers: headers,
});
if (!response.ok) {
this.status = "Failed loading " + this.url + ": " + response.statusText;
return;
}
this.annotations = await response.json();
if (this.annotations.status == 'error') {
alert("Failed to load annotations: " + this.annotations.msg);
return;
}
if (!this.annotations || this.annotations.length === 0) {
this.status = "No annotations found";
return;
}
//this.annotations = this.annotations.map(a => '@context' in a ? Annotation.fromJsonLd(a): a);
this.annotations = this.annotations.map((a, index) => {
const annotation = new Annotation(a);
// Ensure idx is set in data, using the array index if not provided
const currentIdx = this.getAnnotationIdx(annotation);
if (currentIdx === undefined || currentIdx === null) {
this.setAnnotationIdx(annotation, index);
}
annotation.published = (a.publish == 1);
return annotation;
});
for (let a of this.annotations)
if (a.publish != 1)
a.visible = false;
// Sort by idx if available, otherwise maintain original order
this.annotations.sort((a, b) => {
const aIdx = this.getAnnotationIdx(a);
const bIdx = this.getAnnotationIdx(b);
if (aIdx !== null && aIdx !== undefined && bIdx !== null && bIdx !== undefined) {
// Convert to numbers for proper numeric sorting
const aNum = parseInt(aIdx);
const bNum = parseInt(bIdx);
if (!isNaN(aNum) && !isNaN(bNum)) {
return aNum - bNum;
}
// If not numbers, compare as strings
return String(aIdx).localeCompare(String(bIdx));
}
// Fallback to label comparison if idx is not available
return (a.label || '').localeCompare(b.label || '');
});
if (this.annotationsListEntry)
this.createAnnotationsList();
this.emit('update');
this.status = 'ready';
this.emit('ready');
this.emit('loaded');
}
/**
* Creates a new annotation and adds it to the layer
* @param {Annotation} [annotation] - Optional pre-configured annotation
* @returns {Annotation} The newly created annotation
* @private
*/
newAnnotation(annotation) {
if (!annotation) {
// Set idx to the next available index
const maxIdx = Math.max(...this.annotations.map(a => {
const idx = this.getAnnotationIdx(a);
return (idx !== null && idx !== undefined) ? parseInt(idx) || 0 : 0;
}), -1);
annotation = new Annotation({ data: { idx: maxIdx + 1 } });
} else {
const currentIdx = this.getAnnotationIdx(annotation);
if (currentIdx === null || currentIdx === undefined) {
// Ensure new annotations have an idx
const maxIdx = Math.max(...this.annotations.map(a => {
const idx = this.getAnnotationIdx(a);
return (idx !== null && idx !== undefined) ? parseInt(idx) || 0 : 0;
}), -1);
this.setAnnotationIdx(annotation, maxIdx + 1);
}
}
this.annotations.push(annotation);
// Recreate the entire dropdown list to include the new annotation with correct structure
if (this.annotationsListEntry && this.annotationsListEntry.element && this.annotationsListEntry.element.parentElement) {
const list = this.annotationsListEntry.element.parentElement.querySelector('.openlime-list');
if (list) {
// Store current dropdown state
const selectContainer = list.querySelector('.openlime-annotations-select');
const wasActive = selectContainer && selectContainer.classList.contains('active');
// Cleanup previous event listeners if they exist
if (selectContainer && selectContainer._cleanup) {
selectContainer._cleanup();
}
// Recreate the entire annotations list
this.createAnnotationsList();
// Restore dropdown state if it was open
if (wasActive) {
const newSelectContainer = list.querySelector('.openlime-annotations-select');
if (newSelectContainer) {
newSelectContainer.classList.add('active');
}
}
}
}
this.clearSelected();
//this.setSelected(annotation);
return annotation;
}
/**
* Creates the UI entry for the annotations list
* @returns {Object} Configuration object for annotations list UI
* @private
*/
annotationsEntry() {
return this.annotationsListEntry = {
html: '',
list: [], //will be filled later.
classes: 'openlime-annotations',
status: () => 'active',
oncreate: () => {
if (Array.isArray(this.annotations))
this.createAnnotationsList();
}
}
}
/**
* Creates the complete annotations list UI as a dropdown menu with precise positioning
* @private
*/
createAnnotationsList() {
// Create dropdown HTML structure
let html = `
<div class="openlime-select openlime-annotations-select">
<div class="openlime-select-button openlime-annotations-button">
<span class="openlime-annotations-selected-text">Select an annotation</span>
</div>
<ul class="openlime-select-menu openlime-annotations-menu">
${this.annotations.map(a => {
const idx = this.getAnnotationIdx(a);
const displayText = a.label || `Annotation ${(idx !== null && idx !== undefined) ? parseInt(idx) : ''}`;
return `<li data-annotation="${a.id}" class="openlime-annotations-option ${a.visible == 0 ? 'hidden' : ''}" data-visible="${a.visible !== false}">
<span class="openlime-annotations-text">${displayText}</span>
<div class="openlime-annotations-visibility">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="openlime-eye"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="openlime-eye-off"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
</div>
</li>`;
}).join('\n')}
</ul>
</div>`;
let list = this.annotationsListEntry.element.parentElement.querySelector('.openlime-list');
list.innerHTML = html;
// Get references to elements
const selectContainer = list.querySelector('.openlime-annotations-select');
const button = list.querySelector('.openlime-annotations-button');
const menu = list.querySelector('.openlime-annotations-menu');
const selectedText = list.querySelector('.openlime-annotations-selected-text');
const options = list.querySelectorAll('.openlime-annotations-option');
const layersContent = document.querySelector('.openlime-layers-content');
// Function to position dropdown menu precisely
const positionDropdown = () => {
const buttonRect = button.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
// Calculate dropdown dimensions
const dropdownMaxHeight = 180; // Max height from CSS
const actualHeight = Math.min(dropdownMaxHeight, options.length * 18); // Each item ~18px
// Preferred position: directly below the button
let top = buttonRect.bottom;
let left = buttonRect.left;
let width = buttonRect.width;
let showAbove = false;
// Check if dropdown would go off screen vertically
if (top + actualHeight > viewportHeight - 10) {
// Check if there's enough space above
if (buttonRect.top - actualHeight > 10) {
// Show above button
top = buttonRect.top - actualHeight;
showAbove = true;
} else {
// Keep below but adjust height if needed
const availableHeight = viewportHeight - top - 10;
if (availableHeight < actualHeight) {
menu.style.maxHeight = `${availableHeight}px`;
}
}
}
// Check if dropdown would go off screen horizontally
if (left + width > viewportWidth - 10) {
left = Math.max(10, viewportWidth - width - 10);
}
// Apply positioning with precise alignment
menu.style.top = `${top}px`;
menu.style.left = `${left}px`;
menu.style.width = `${width}px`;
menu.style.minWidth = `${width}px`;
// Adjust border radius based on position
if (showAbove) {
menu.style.borderRadius = '6px 6px 0 0';
button.style.borderRadius = '0 0 6px 6px';
} else {
menu.style.borderRadius = '0 0 6px 6px';
button.style.borderRadius = '6px 6px 0 0';
}
};
// Handle dropdown toggle
button.addEventListener('click', (e) => {
e.stopPropagation();
const isActive = selectContainer.classList.contains('active');
if (!isActive) {
// Opening dropdown
selectContainer.classList.add('active');
layersContent.classList.add('dropdown-open');
// Position dropdown immediately and precisely
requestAnimationFrame(() => {
positionDropdown();
});
} else {
// Closing dropdown
selectContainer.classList.remove('active');
layersContent.classList.remove('dropdown-open');
// Reset button border radius
button.style.borderRadius = '6px';
menu.style.maxHeight = '180px'; // Reset max height
}
});
// Handle option selection and visibility toggle
menu.addEventListener('click', (e) => {
e.stopPropagation();
// Check if clicked on visibility icon
const visibilityDiv = e.target.closest('.openlime-annotations-visibility');
if (visibilityDiv) {
e.preventDefault();
const option = visibilityDiv.closest('.openlime-annotations-option');
const id = option.getAttribute('data-annotation');
const anno = this.getAnnotationById(id);
// Toggle visibility
anno.visible = !anno.visible;
anno.needsUpdate = true;
// Update UI
option.classList.toggle('hidden', !anno.visible);
option.setAttribute('data-visible', anno.visible);
this.emit('update');
return;
}
// Handle annotation selection
const option = e.target.closest('.openlime-annotations-option');
if (option) {
const id = option.getAttribute('data-annotation');
const anno = this.getAnnotationById(id);
// Update selected text
const text = option.querySelector('.openlime-annotations-text').textContent;
selectedText.textContent = text;
// Clear previous selection and set new one
options.forEach(opt => opt.classList.remove('selected'));
option.classList.add('selected');
// Close dropdown
selectContainer.classList.remove('active');
layersContent.classList.remove('dropdown-open');
// Reset button border radius
button.style.borderRadius = '6px';
menu.style.maxHeight = '180px'; // Reset max height
// Clear and set selection
this.clearSelected();
this.setSelected(anno, true);
}
});
// Close dropdown when clicking outside
const closeDropdown = (e) => {
if (!selectContainer.contains(e.target)) {
selectContainer.classList.remove('active');
layersContent.classList.remove('dropdown-open');
button.style.borderRadius = '6px';
menu.style.maxHeight = '180px';
}
};
// Event listeners for closing dropdown
document.addEventListener('click', closeDropdown);
// Close dropdown on scroll or resize and reposition if still open
const handleScrollResize = () => {
if (selectContainer.classList.contains('active')) {
// Try to reposition, or close if not possible
requestAnimationFrame(() => {
positionDropdown();
});
}
};
window.addEventListener('resize', handleScrollResize);
layersContent.addEventListener('scroll', handleScrollResize);
// Reposition dropdown when layers menu is moved
const observer = new MutationObserver(() => {
if (selectContainer.classList.contains('active')) {
requestAnimationFrame(() => {
positionDropdown();
});
}
});
observer.observe(layersContent.parentElement, {
attributes: true,
attributeFilter: ['class', 'style']
});
// Store cleanup function
selectContainer._cleanup = () => {
document.removeEventListener('click', closeDropdown);
window.removeEventListener('resize', handleScrollResize);
layersContent.removeEventListener('scroll', handleScrollResize);
observer.disconnect();
};
}
/**
* Creates a single annotation entry for the UI
* @param {Annotation} annotation - The annotation to create an entry for
* @returns {string} HTML string for the annotation entry
* @private
*/
createAnnotationEntry(a) {
const idx = this.getAnnotationIdx(a);
const displayText = a.label || `Annotation ${(idx !== null && idx !== undefined) ? parseInt(idx) : ''}`;
return `<a href="#" data-annotation="${a.id}" class="openlime-entry ${a.visible == 0 ? 'hidden' : ''}">${displayText}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="openlime-eye"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="openlime-eye-off"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
</a>`;
}
/**
* Retrieves an annotation by its ID
* @param {string} id - Annotation identifier
* @returns {Annotation|null} The found annotation or null if not found
*/
getAnnotationById(id) {
for (const anno of this.annotations)
if (anno.id == id)
return anno;
return null;
}
/**
* Retrieves an annotation by its index
* @param {number|string} idx - Annotation index
* @returns {Annotation|null} The found annotation or null if not found
*/
getAnnotationByIdx(idx) {
for (const anno of this.annotations) {
const annoIdx = this.getAnnotationIdx(anno);
// Compare both as strings and numbers to handle different data types
if (annoIdx == idx || (parseInt(annoIdx) === parseInt(idx) && !isNaN(parseInt(idx))))
return anno;
}
return null;
}
/**
* Clears all annotation selections
* @private
*/
clearSelected() {
// Check if DOM elements are available
if (!this.annotationsListEntry || !this.annotationsListEntry.element || !this.annotationsListEntry.element.parentElement) {
// Clear internal selection only
this.selected.clear();
return;
}
// Clear dropdown selections
const list = this.annotationsListEntry.element.parentElement.querySelector('.openlime-list');
if (!list) {
this.selected.clear();
return;
}
const options = list.querySelectorAll('.openlime-annotations-option');
const selectedText = list.querySelector('.openlime-annotations-selected-text');
// Remove selected class from all options
options.forEach(opt => opt.classList.remove('selected'));
// Reset dropdown text
if (selectedText) {
selectedText.textContent = "Select an annotation";
}
// Clear internal selection
this.selected.clear();
}
/**
* Updates the dropdown selection when annotation is selected programmatically
* @param {Annotation} anno - The annotation to select/deselect
* @param {boolean} [on=true] - Whether to select (true) or deselect (false)
* @fires LayerAnnotation#selected
*/
setSelected(anno, on = true) {
// Check if DOM elements are available
if (!this.annotationsListEntry || !this.annotationsListEntry.element || !this.annotationsListEntry.element.parentElement) {
// Update internal selection only
if (on) {
this.selected.add(anno.id);
} else {
this.selected.delete(anno.id);
}
this.emit('selected', anno);
return;
}
// Update dropdown selection
const list = this.annotationsListEntry.element.parentElement.querySelector('.openlime-list');
if (!list) {
// Update internal selection only
if (on) {
this.selected.add(anno.id);
} else {
this.selected.delete(anno.id);
}
this.emit('selected', anno);
return;
}
const options = list.querySelectorAll('.openlime-annotations-option');
const selectedText = list.querySelector('.openlime-annotations-selected-text');
if (on) {
// Clear previous selections
options.forEach(opt => opt.classList.remove('selected'));
// Find and select the correct option
const targetOption = list.querySelector(`[data-annotation="${anno.id}"]`);
if (targetOption) {
targetOption.classList.add('selected');
const text = targetOption.querySelector('.openlime-annotations-text').textContent;
if (selectedText) {
selectedText.textContent = text;
}
}
this.selected.add(anno.id);
} else {
// Deselect
const targetOption = list.querySelector(`[data-annotation="${anno.id}"]`);
if (targetOption) {
targetOption.classList.remove('selected');
}
// Reset to default text if nothing selected
if (this.selected.size === 0 && selectedText) {
selectedText.textContent = "Select an annotation";
}
this.selected.delete(anno.id);
}
this.emit('selected', anno);
}
}
addSignals(LayerAnnotation, 'selected', 'loaded');
export { LayerAnnotation }