import { Util } from './Util' import { Layer } from './Layer' import { Annotation } from './Annotation' import { LayerAnnotation } from './LayerAnnotation' import { CoordinateSystem } from './CoordinateSystem'; /** * @typedef {Object} AnnoClass * @property {string} stroke - CSS color for SVG elements (lines, text, outlines) * @property {string} label - Display name for the class */ /** * @typedef {Object.<string, AnnoClass>} AnnoClasses * @description Map of class names to their visual properties */ /** * @typedef {Object} LayerSvgAnnotationOptions * @property {AnnoClasses} classes - Annotation class definitions with styles * @property {Function} [onClick] - Callback for annotation click events (param: selected annotation) * @property {boolean} [shadow=true] - Whether to use Shadow DOM for SVG elements * @property {HTMLElement} [overlayElement] - Container for SVG overlay * @property {string} [style] - Additional CSS styles for annotations * @property {Function} [annotationUpdate] - Custom update function for annotations * @extends LayerAnnotationOptions */ /** * LayerSvgAnnotation provides SVG-based annotation capabilities in OpenLIME. * It renders SVG elements directly on the canvas overlay, outside the WebGL context, * enabling rich vector graphics annotations with interactive features. * * Features: * - SVG-based vector annotations * - Custom styling per annotation class * - Interactive selection * - Shadow DOM isolation * - Dynamic SVG transformation * - Event handling * - Custom update callbacks * * Technical Details: * - Uses SVG overlay for rendering * - Handles coordinate system transformations * - Manages DOM element lifecycle * - Supports custom class styling * - Implements visibility management * - Provides selection mechanisms * * @extends LayerAnnotation * * @example * ```javascript * // Create SVG annotation layer with custom classes * const annotationLayer = new OpenLIME.Layer({ * type: 'svg_annotations', * classes: { * 'highlight': { stroke: '#ff0', label: 'Highlight' }, * 'comment': { stroke: '#0f0', label: 'Comment' } * }, * onClick: (annotation) => { * console.log('Clicked:', annotation.label); * }, * shadow: true * }); * * // Add to viewer * viewer.addLayer('annotations', annotationLayer); * ``` */ class LayerSvgAnnotation extends LayerAnnotation { /** * Creates a new LayerSvgAnnotation instance * @param {LayerSvgAnnotationOptions} [options] - Configuration options */ constructor(options) { options = Object.assign({ overlayElement: null, //reference to canvas overlayElement. TODO: check if really needed. shadow: true, //svg attached as shadow node (so style apply only the svg layer) svgElement: null, //the svg layer svgGroup: null, onClick: null, //callback function classes: { '': { stroke: '#000', label: '' }, }, annotationUpdate: null }, options); super(options); for (const [key, value] of Object.entries(this.classes)) { this.style += `[data-class=${key}] { ` + Object.entries(value).map(g => `${g[0]}: ${g[1]};`).join('\n') + '}'; } this.style += `.openlime-svgoverlay { position:absolute; top:0px; left:0px;}`; //this.createOverlaySVGElement(); //this.setLayout(this.layout); } /** * Creates the SVG overlay element and initializes the shadow DOM if enabled * @private */ createOverlaySVGElement() { this.svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svgElement.classList.add('openlime-svgoverlay'); this.svgGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); this.svgElement.append(this.svgGroup); // Check if the shadow root already exists before attaching let root = this.overlayElement; if (this.shadow) { if (!this.overlayElement.shadowRoot) { root = this.overlayElement.attachShadow({ mode: "open" }); } else { root = this.overlayElement.shadowRoot; // Use existing shadow root } } if (this.style) { const style = document.createElement('style'); style.textContent = this.style; root.append(style); } root.appendChild(this.svgElement); } /* unused for the moment!!! async loadSVG(url) { var response = await fetch(url); if (!response.ok) { this.status = "Failed loading " + this.url + ": " + response.statusText; return; } let text = await response.text(); let parser = new DOMParser(); this.svgXML = parser.parseFromString(text, "image/svg+xml").documentElement; throw "if viewbox is set in svgURL should it overwrite options.viewbox or viceversa?" } */ /** * Sets visibility of the annotation layer * Updates both SVG display and underlying layer visibility * @param {boolean} visible - Whether layer should be visible * @override */ setVisible(visible) { if (this.svgElement) this.svgElement.style.display = visible ? 'block' : 'none'; super.setVisible(visible); } /** * Clears all annotation selections */ clearSelected() { if (!this.svgElement) this.createOverlaySVGElement(); // return; this.svgGroup.querySelectorAll('[data-annotation]').forEach((e) => e.classList.remove('selected')); super.clearSelected(); } /** * Sets selection state of an annotation * @param {Annotation} anno - The annotation to select/deselect * @param {boolean} [on=true] - Whether to select (true) or deselect (false) */ setSelected(anno, on = true) { for (let a of this.svgElement.querySelectorAll(`[data-annotation="${anno.id}"]`)) a.classList.toggle('selected', on); super.setSelected(anno, on); } /** * Creates a new SVG annotation * @param {Annotation} [annotation] - Optional existing annotation to use * @returns {Annotation} The created annotation * @private */ newAnnotation(annotation) { let svg = Util.createSVGElement('svg'); if (!annotation) annotation = new Annotation({ element: svg, selector_type: 'SvgSelector' }); return super.newAnnotation(annotation) } /** * Renders the SVG annotations * Updates SVG viewBox and transformation to match current view * @param {Transform} transform - Current view transform * @param {Object} viewport - Current viewport * @returns {boolean} Whether render completed successfully * @override */ draw(transform, viewport) { if (!this.svgElement) return true; this.svgElement.setAttribute('viewBox', `${-viewport.w / 2} ${-viewport.h / 2} ${viewport.w} ${viewport.h}`); const svgTransform = this.getSvgGroupTransform(transform); this.svgGroup.setAttribute("transform", svgTransform); return true; } /** * Calculates SVG group transform string * @param {Transform} transform - Current view transform * @param {boolean} [inverse=false] - Whether to return inverse transform * @returns {string} SVG transform attribute value */ getSvgGroupTransform(transform, inverse = false) { let t = this.transform.compose(transform); let c = this.boundingBox().corner(0); // FIXME CHECK IT: Convert from GL to SVG, but without any scaling. It just needs to reflect around 0, t = CoordinateSystem.reflectY(t); return inverse ? `translate(${-c.x} ${-c.y}) scale(${1 / t.z} ${1 / t.z}) rotate(${t.a} 0 0) translate(${-t.x} ${-t.y})` : `translate(${t.x} ${t.y}) rotate(${-t.a} 0 0) scale(${t.z} ${t.z}) translate(${c.x} ${c.y})`; } /** * Prepares annotations for rendering * Handles SVG element creation and updates * @param {Transform} transform - Current view transform * @private */ prefetch(transform) { if (!this.svgElement) this.createOverlaySVGElement(); if (!this.visible) return; if (this.status != 'ready') return; if (typeof (this.annotations) == "string") return; //FIXME Is it right? Should we use this.status? const bBox = this.boundingBox(); //this.svgElement.setAttribute('viewBox', `${bBox.xLow} ${bBox.yLow} ${bBox.xHigh - bBox.xLow} ${bBox.yHigh - bBox.yLow}`); //find which annotations needs to be added to the ccanvas, some //indexing whould be used, for the moment we just iterate all of them. for (let anno of this.annotations) { //TODO check for class visibility and bbox culling (or maybe should go to prefetch?) if (!anno.ready && typeof anno.svg == 'string') { let parser = new DOMParser(); let element = parser.parseFromString(anno.svg, "image/svg+xml").documentElement; anno.elements = [...element.children] anno.ready = true; /* } else if(this.svgXML) { a.svgElement = this.svgXML.querySelector(`#${a.id}`); if(!a.svgElement) throw Error(`Could not find element with id: ${id} in svg`); } */ } if (this.annotationUpdate) this.annotationUpdate(anno, transform); if (!anno.needsUpdate) continue; anno.needsUpdate = false; for (let e of this.svgGroup.querySelectorAll(`[data-annotation="${anno.id}"]`)) e.remove(); if (!anno.visible) continue; //second time will be 0 elements, but we need to //store somewhere knowledge of which items in the scene and which still not. for (let child of anno.elements) { let c = child; //.cloneNode(true); c.setAttribute('data-annotation', anno.id); c.setAttribute('data-class', anno.class); //c.setAttribute('data-layer', this.id); c.classList.add('openlime-annotation'); if (this.selected.has(anno.id)) c.classList.add('selected'); this.svgGroup.appendChild(c); c.onpointerdown = (e) => { if (e.button == 0) { e.preventDefault(); e.stopPropagation(); if (this.onClick && this.onClick(anno)) return; if (this.selected.has(anno.id)) return; this.clearSelected(); this.setSelected(anno, true); } } } } } } /** * Register this layer type with the Layer factory * @type {Function} * @private */ Layer.prototype.types['svg_annotations'] = (options) => { return new LayerSvgAnnotation(options); } export { LayerSvgAnnotation }