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 = layer;
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 };