import { CoordinateSystem } from "./CoordinateSystem"; import { Util } from "./Util" /* * @fileoverview * LensDashboard module provides functionality for creating and managing an interactive lens interface * in OpenLIME. It handles the lens border, SVG masking, and positioning of UI elements around the lens. */ /** * @enum {string} * Defines rendering modes for lens and background areas. * @property {string} draw - "fill:white;" Shows content in the specified area * @property {string} hide - "fill:black;" Hides content in the specified area */ const RenderingMode = { draw: "fill:white;", hide: "fill:black;" }; /** * Callback function fired by a 'click' event on a lens dashboard element. * @function taskCallback * @param {Event} e The DOM event. */ /** * LensDashboard class creates an interactive container for a lens interface. * It provides: * - A square HTML container that moves with the lens * - SVG-based circular lens border with drag interaction for resizing * - Masking capabilities for controlling content visibility inside/outside the lens * - Ability to add HTML elements positioned relative to the lens */ class LensDashboard { /** * Creates a new LensDashboard instance. * @param {Viewer} viewer - The OpenLIME viewer instance * @param {Object} [options] - Configuration options * @param {number} [options.containerSpace=80] - Extra space around the lens for dashboard elements (in pixels) * @param {number[]} [options.borderColor=[0.078, 0.078, 0.078, 1]] - RGBA color for lens border * @param {number} [options.borderWidth=12] - Width of the lens border (in pixels) * @param {LayerSvgAnnotation} [options.layerSvgAnnotation=null] - Associated SVG annotation layer */ constructor(viewer, options) { options = Object.assign({ containerSpace: 80, borderColor: [0.078, 0.078, 0.078, 1], borderWidth: 12, layerSvgAnnotation: null }, options); Object.assign(this, options); this.lensLayer = null; this.viewer = viewer; this.elements = []; this.container = document.createElement('div'); this.container.style = `position: absolute; width: 50px; height: 50px; background-color: rgb(200, 0, 0, 0.0); pointer-events: none`; this.container.classList.add('openlime-lens-dashboard'); this.viewer.containerElement.appendChild(this.container); const col = [255.0 * this.borderColor[0], 255.0 * this.borderColor[1], 255.0 * this.borderColor[2], 255.0 * this.borderColor[3]]; this.lensElm = Util.createSVGElement('svg', { viewBox: `0 0 100 100` }); const circle = Util.createSVGElement('circle', { cx: 10, cy: 10, r: 50 }); circle.setAttributeNS(null, 'style', `position:absolute; visibility: visible; fill: none; stroke: rgb(${col[0]},${col[1]},${col[2]},${col[3]}); stroke-width: ${this.borderWidth}px;`); circle.setAttributeNS(null, 'shape-rendering', 'geometricPrecision'); this.lensElm.appendChild(circle); this.container.appendChild(this.lensElm); this.setupCircleInteraction(circle); this.lensBox = { x: 0, y: 0, r: 0, w: 0, h: 0 }; this.svgElement = null; this.svgMaskId = 'openlime-image-mask'; this.svgMaskUrl = `url(#${this.svgMaskId})`; this.noupdate = false; } /** * Sets up interactive lens border resizing. * Creates event listeners for pointer events to allow users to drag the lens border to resize. * @private * @param {SVGElement} circle - The SVG circle element representing the lens border */ setupCircleInteraction(circle) { circle.style.pointerEvents = 'auto'; this.isCircleSelected = false; // OffsetXY are unstable from this point (I don't know why) // Thus get coordinates from clientXY function getXYFromEvent(e, container) { const x = e.clientX - container.offsetLeft - container.clientLeft; const y = e.clientY - container.offsetTop - container.clientTop; return { offsetX: x, offsetY: y }; } this.viewer.containerElement.addEventListener('pointerdown', (e) => { if (circle == e.target) { this.isCircleSelected = true; if (this.lensLayer.controllers[0]) { const p = getXYFromEvent(e, this.viewer.containerElement); this.lensLayer.controllers[0].zoomStart(p); } e.preventDefault(); e.stopPropagation(); } }); this.viewer.containerElement.addEventListener('pointermove', (e) => { if (this.isCircleSelected) { if (this.lensLayer.controllers[0]) { const p = getXYFromEvent(e, this.viewer.containerElement); this.lensLayer.controllers[0].zoomMove(p); } e.preventDefault(); e.stopPropagation(); } }); this.viewer.containerElement.addEventListener('pointerup', (e) => { if (this.isCircleSelected) { if (this.lensLayer.controllers[0]) { this.lensLayer.controllers[0].zoomEnd(); } this.isCircleSelected = false; e.preventDefault(); e.stopPropagation(); } }); } /** * Toggles the visibility of the dashboard UI elements. * Uses CSS classes to show/hide the interface. */ toggle() { this.container.classList.toggle('closed'); } /** * Associates a LayerSvgAnnotation with the dashboard. * This enables proper masking of SVG annotations within the lens area. * @param {LayerSvgAnnotation} layer - The SVG annotation layer to associate */ setLayerSvgAnnotation(layer) { this.layerSvgAnnotation = l; this.svgElement = this.layerSvgAnnotation.svgElement; } /** * Creates SVG masking elements for the lens. * Sets up a composite mask consisting of: * - A full-viewport rectangle for the background * - A circle for the lens area * The mask controls visibility of content inside vs outside the lens. * @private */ createSvgLensMask() { if (this.svgElement == null) this.setupSvgElement(); if (this.svgElement == null) return; // Create a mask made of a rectangle (it will be set to the full viewport) for the background // And a circle, corresponding to the lens. const w = 100; // The real size will be set at each frame by the update function this.svgMask = Util.createSVGElement("mask", { id: this.svgMaskId }); this.svgGroup = Util.createSVGElement("g"); this.outMask = Util.createSVGElement("rect", { id: 'outside-lens-mask', x: -w / 2, y: -w / 2, width: w, height: w, style: "fill:black;" }); this.inMask = Util.createSVGElement("circle", { id: 'inside-lens-mask', cx: 0, cy: 0, r: w / 2, style: "fill:white;" }); this.svgGroup.appendChild(this.outMask); this.svgGroup.appendChild(this.inMask); this.svgMask.appendChild(this.svgGroup); this.svgElement.appendChild(this.svgMask); // FIXME Remove svgCheck. It's a Check, just to have an SVG element to mask // this.svgCheck = Util.createSVGElement('rect', {x:-w/2, y:-w/2, width:w/2, height:w/2, style:'fill:orange; stroke:blue; stroke-width:5px;'}); // // this.svgCheck.setAttribute('mask', this.svgMaskUrl); // this.svgElement.appendChild(this.svgCheck); // console.log(this.svgCheck); } /** * Sets up the SVG container element for the lens. * Will either: * - Use the SVG element from an associated annotation layer * - Find an existing SVG element in the shadow DOM * - Create a new SVG element if needed * @private */ setupSvgElement() { if (this.layerSvgAnnotation) { // AnnotationLayer available, get its root svgElement if (this.svgElement == null) { //console.log("NULL SVG ELEMENT, take it from layerSvgAnnotation"); this.svgElement = this.layerSvgAnnotation.svgElement; } } else { // No annotationLayer, search for an svgElement // First: get shadowRoot to attach the svgElement let shadowRoot = this.viewer.canvas.overlayElement.shadowRoot; if (shadowRoot == null) { //console.log("WARNING: null ShadowRoot, create a new one"); shadowRoot = this.viewer.canvas.overlayElement.attachShadow({ mode: "open" }); } //console.log("WARNING: no svg element, create a new one"); this.svgElement = shadowRoot.querySelector('svg'); if (this.svgElement == null) { // Not availale svg element: build a new one and attach to the tree this.svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); this.svgElement.classList.add('openlime-svgoverlay-mask'); this.svgElement.setAttributeNS(null, 'style', 'pointer-events: none;'); shadowRoot.appendChild(this.svgElement); } } } /** * Applies the lens mask to an SVG element. * Elements with the mask will only be visible within the lens area * (or outside, depending on mask configuration). * @param {SVGElement} svg - The SVG element to mask */ setMaskOnSvgLayer(svg) { svg.setAttributeNS(null, 'mask', this.svgMaskUrl); } /** * Removes the lens mask from an SVG element. * Returns the element to its normal, unmasked rendering. * @param {SVGElement} svg - The SVG element to unmask */ removeMaskFromSvgLayer(svg) { svg.removeAttribute('mask'); } /** * Adds an HTML element to the dashboard container. * The element should use absolute positioning relative to the container. * Example: * ```javascript * const button = document.createElement('button'); * button.style = 'position: absolute; left: 10px; top: 10px;'; * lensDashboard.append(button); * ``` * @param {HTMLElement} elm - The HTML element to add */ append(elm) { this.container.appendChild(elm); } /** * Sets the rendering mode for the lens area. * Controls whether content inside the lens is shown or hidden. * @param {RenderingMode} mode - The rendering mode to use */ setLensRenderingMode(mode) { this.inMask.setAttributeNS(null, 'style', mode); } /** * Sets the rendering mode for the background (area outside the lens). * Controls whether content outside the lens is shown or hidden. * @param {RenderingMode} mode - The rendering mode to use */ setBackgroundRenderingMode(mode) { this.outMask.setAttributeNS(null, 'style', mode); } /** * Updates the dashboard position and size. * Called internally when the lens moves or resizes. * @private * @param {number} x - Center X coordinate in scene space * @param {number} y - Center Y coordinate in scene space * @param {number} r - Lens radius in scene space */ update(x, y, r) { const useGL = false; const center = CoordinateSystem.fromSceneToCanvasHtml({ x: x, y: y }, this.viewer.camera, useGL); const now = performance.now(); let cameraT = this.viewer.camera.getCurrentTransform(now); const radius = r * cameraT.z; const sizew = 2 * radius + 2 * this.containerSpace; const sizeh = 2 * radius + 2 * this.containerSpace; const p = { x: 0, y: 0 }; p.x = center.x - radius - this.containerSpace; p.y = center.y - radius - this.containerSpace; this.container.style.left = `${p.x}px`; this.container.style.top = `${p.y}px`; this.container.style.width = `${sizew}px`; this.container.style.height = `${sizeh}px`; // Lens circle if (sizew != this.lensBox.w || sizeh != this.lensBox.h) { const cx = Math.ceil(sizew * 0.5); const cy = Math.ceil(sizeh * 0.5); this.lensElm.setAttributeNS(null, 'viewBox', `0 0 ${sizew} ${sizeh}`); const circle = this.lensElm.querySelector('circle'); circle.setAttributeNS(null, 'cx', cx); circle.setAttributeNS(null, 'cy', cy); circle.setAttributeNS(null, 'r', radius - 0.5 * this.borderWidth); } this.updateMask(cameraT, center, radius); this.lensBox = { x: center.x, y: center.y, r: radius, w: sizew, h: sizeh } } /** * Updates the SVG mask position and size. * Called internally by update() to keep the mask aligned with the lens. * @private * @param {Transform} cameraT - Current camera transform * @param {Object} center - Lens center in canvas coordinates * @param {number} center.x - Center X coordinate * @param {number} center.y - Center Y coordinate * @param {number} radius - Lens radius in canvas coordinates */ updateMask(cameraT, center, radius) { if (this.svgElement == null) { this.createSvgLensMask(); } if (this.svgElement == null) return; // Lens Mask const viewport = this.viewer.camera.viewport; if (this.layerSvgAnnotation != null) { // Compensate the mask transform with the inverse of the annotation svgGroup transform const inverse = true; const invTransfStr = this.layerSvgAnnotation.getSvgGroupTransform(cameraT, inverse); this.svgGroup.setAttribute("transform", invTransfStr); } else { // Set the viewbox. (in the other branch it is set by the layerSvgAnnotation) this.svgElement.setAttribute('viewBox', `${-viewport.w / 2} ${-viewport.h / 2} ${viewport.w} ${viewport.h}`); } // Set the full viewport for outer mask rectangle this.outMask.setAttribute('x', -viewport.w / 2); this.outMask.setAttribute('y', -viewport.h / 2); this.outMask.setAttribute('width', viewport.w); this.outMask.setAttribute('height', viewport.h); // Set lens parameter for inner lens this.inMask.setAttributeNS(null, 'cx', center.x - viewport.w / 2); this.inMask.setAttributeNS(null, 'cy', center.y - viewport.h / 2); this.inMask.setAttributeNS(null, 'r', radius - this.borderWidth - 2); } } /** * Example usage of LensDashboard: * ```javascript * const lensDashboard = new OpenLIME.LensDashboard(limeViewer, { * containerSpace: 100, * borderColor: [0.1, 0.1, 0.1, 1], * borderWidth: 10 * }); * * const lensLayer = new OpenLIME.Layer({ * type: "lens", * layers: [innerLayer], * camera: limeViewer.camera, * radius: 200, * dashboard: lensDashboard, * visible: true * }); * * const button = document.createElement('button'); * button.innerHTML = "Toggle Layer"; * button.style = ` * position: absolute; * left: 10px; * top: 10px; * pointer-events: auto; * `; * lensDashboard.append(button); * ``` */ export { LensDashboard, RenderingMode };