Source: overlays/fieldofview/fieldofview.mjs

/**
 * Field of view overlay: physical diameter (mpp-backed) with preview on toolLayer
 * and committed circles on the active TiledImage Paper layer (image-registered).
 */

import { PaperOverlay } from '../../paper-overlay.mjs';
import { paper } from '../../paperjs.mjs';
import { domObjectFromHTML } from '../../utils/domObjectFromHTML.mjs';
import { loadFieldOfViewSettings, normalizeFieldOfViewSettings, saveFieldOfViewSettings } from './fieldofview-settings.mjs';
import {
    areaMm2FromDiameterPhysical,
    diameterPhysicalFromAreaMm2,
    diameterPhysicalToBasePixels,
    mppFromTiledImage,
} from './fieldofview-geometry.mjs';
import { FieldOfViewTool } from './fieldofview-tool.mjs';
import { ViewerOverlayBase } from '../base.mjs';

const FOV_DEBUG = true;
/** @param {...unknown} args */
function fovOverlayLog(...args) {
    if (FOV_DEBUG) console.log('[FOV overlay]', ...args);
}

/**
 * @param {paper.PaperScope} paperScope
 */
function ensureNamedToolLayer(paperScope) {
    if (paperScope.project.layers.toolLayer) {
        fovOverlayLog('ensureNamedToolLayer: toolLayer already present', {
            name: paperScope.project.layers.toolLayer.name,
        });
        return;
    }
    const toolLayer = new paperScope.Layer();
    toolLayer.isGeoJSONFeatureCollection = false;
    toolLayer.name = 'toolLayer';
    toolLayer.applyMatrix = false;
    paperScope.project.addLayer(toolLayer);
    fovOverlayLog('ensureNamedToolLayer: created toolLayer', { name: toolLayer.name });
}

class FieldOfViewOverlay extends ViewerOverlayBase {
    static get label() { return 'Field of view'; }
    static get faIconClass() { return 'fa-microscope'; }

    /**
     * @param {OpenSeadragon.Viewer} viewer
     * @param {Object} [opts]
     * @param {boolean} [opts.registerWithConfig=true] Set false to suppress auto-registration with ConfigurationWidget
     */
    constructor(viewer, opts = {}) {
        super(viewer, opts);
        this.overlay = new PaperOverlay(viewer, { overlayType: 'image' });
        ensureNamedToolLayer(this.overlay.paperScope);

        // Same wiring as AnnotationToolkit: zoom-driven rescale for items with a `rescale` map.
        this.overlay.autoRescaleItems(true);

        this.tool = new FieldOfViewTool(this.overlay.paperScope, this);
        this.dummyTool = new this.overlay.paperScope.Tool();
        this.dummyTool.activate();

        this._mouseNavEnabledAtActivation = true;
        this._state = 'inactive';
        this._saveSettingsTimeout = null;
        /** @type {HTMLElement | null} */
        this._fovContextMenuEl = null;
        this._boundFovContextMenu = (ev) => this._onFovCanvasContextMenu(ev);
        this._fovContextMenuPointerDismiss = (e) => {
            if (!this._fovContextMenuEl || this._fovContextMenuEl.contains(e.target)) return;
            this._dismissFovContextMenu();
        };
        this._fovContextMenuKeyDismiss = (e) => {
            if (e.key === 'Escape') {
                this._dismissFovContextMenu();
            }
        };
        this._fovContextMenuBlurDismiss = () => {
            this._dismissFovContextMenu();
        };
        this._fovContextMenuScrollDismiss = () => {
            this._dismissFovContextMenu();
        };

        this.settings = loadFieldOfViewSettings();

        this._onWorldItemCount = () => {
            const n = this.viewer.world?.getItemCount?.() ?? 0;
            if (this._active && n !== 1) {
                this.deactivate();
            }
            // New tile layers are registered after our toolLayer; keep preview/tool chrome on top.
            const tl = this.overlay.paperScope.project.layers.toolLayer;
            if (tl) {
                tl.bringToFront();
            }
        };
        this.viewer.world.addHandler('add-item', this._onWorldItemCount);
        this.viewer.world.addHandler('remove-item', this._onWorldItemCount);

        this.button = this.overlay.addViewerButton({
            faIconClass: 'fa-microscope',
            tooltip: 'Field of view',
            onClick: () => {
                this._active ? this.deactivate() : this.activate();
            },
        });
        this.button.element.querySelector('svg.icon')?.style.setProperty('width', '1em');

        this._makeDialog();

        // Context menu for dropped FOV rings: only this overlay's canvas receives the event; if another
        // PaperOverlay is stacked above, it captures pointer events first (same as any stacked canvas).
        const viewEl = this.overlay.paperScope.view.element;
        viewEl.addEventListener('contextmenu', this._boundFovContextMenu);
        this.viewer.addOnceHandler('destroy', () => {
            viewEl.removeEventListener('contextmenu', this._boundFovContextMenu);
            this._dismissFovContextMenu();
        });

        const worldCount = this.viewer.world?.getItemCount?.() ?? 0;
        fovOverlayLog('constructor done', {
            overlayType: this.overlay.overlayType,
            worldItemCount: worldCount,
            hasDropLayerHint: worldCount === 1 ? Boolean(this.getDropTargetLayer()) : null,
        });

        this._autoRegister();
    }

    activate() {
        const reactivate = this.overlay.setOSDMouseNavEnabled(false);
        this._mouseNavEnabledAtActivation = this._mouseNavEnabledAtActivation || reactivate;
        this.overlay.bringToFront();
        this._setActive(true);
        fovOverlayLog('activate', {
            setOSDMouseNavDisabled: true,
            reactivateHint: reactivate,
            worldItemCount: this.viewer.world?.getItemCount?.() ?? 0,
        });
        this.tool.activate();
        this.tool.setMode('idle');
        this._setState('config');
    }

    deactivate() {
        fovOverlayLog('deactivate');
        this._setActive(false);
        this._setState('inactive');
        this.tool.setMode('idle');
        this.tool.deactivate(true);
        this.dummyTool.activate();
        this.overlay.setOSDMouseNavEnabled(this._mouseNavEnabledAtActivation);
        this._mouseNavEnabledAtActivation = false;
        this.overlay.sendToBack();
    }

    /**
     * @returns {OpenSeadragon.TiledImage}
     */
    _resolveActiveTiledImageOrThrow() {
        const world = this.viewer?.world;
        const count = world?.getItemCount ? world.getItemCount() : 0;
        if (count === 0) {
            throw new Error('Field of view overlay: no active image found (viewer.world has 0 items).');
        }
        if (count > 1) {
            throw new Error('Field of view overlay: multiple images are active; this overlay supports exactly one active image.');
        }
        return world.getItemAt(0);
    }

    /**
     * Paper layer for this overlay’s scope on the active tile (image-local coordinates).
     * @returns {paper.Layer | null}
     */
    getDropTargetLayer() {
        try {
            const ti = this._resolveActiveTiledImageOrThrow();
            const layer = this.overlay.getPaperLayer(ti) ?? null;
            fovOverlayLog('getDropTargetLayer', {
                mapSize: ti._paperLayerMap.size,
                hasLayer: Boolean(layer),
                layerName: layer?.name,
                tiledImageIndex: 0,
            });
            return layer;
        } catch (err) {
            fovOverlayLog('getDropTargetLayer failed', {
                message: err instanceof Error ? err.message : String(err),
            });
            return null;
        }
    }

    /**
     * Remove all committed field-of-view rings from the active tile Paper layer.
     */
    clearAllDroppedFieldOfViews() {
        const layer = this.getDropTargetLayer();
        if (!layer || !layer.children) return;
        const toRemove = [];
        for (let i = 0; i < layer.children.length; i++) {
            const c = layer.children[i];
            if (c.name === 'fovDroppedRing' || c.data?.kind === 'fieldOfView') {
                toRemove.push(c);
            }
        }
        for (const item of toRemove) {
            item.remove();
        }
        this._syncDialogFromSettings();
    }

    /**
     * @returns {number}
     */
    countDroppedFovRings() {
        const layer = this.getDropTargetLayer();
        if (!layer?.children) return 0;
        let n = 0;
        for (let i = 0; i < layer.children.length; i++) {
            const c = layer.children[i];
            if (c.name === 'fovDroppedRing' || c.data?.kind === 'fieldOfView') n++;
        }
        return n;
    }

    /**
     * @param {paper.Point | null} projectPoint
     * @returns {paper.Item | null}
     */
    hitTestFovRingAtProjectPoint(projectPoint) {
        const layer = this.getDropTargetLayer();
        if (!layer || !projectPoint) return null;
        const view = this.overlay.paperScope.view;
        const tolerance = this._fovHitTolerance(view, layer);
        const hit = layer.hitTest(projectPoint, {
            fill: true,
            stroke: true,
            segments: true,
            tolerance,
            match: (hr) => Boolean(this._findFovDroppedRingGroup(hr.item)),
        });
        return hit ? this._findFovDroppedRingGroup(hit.item) : null;
    }

    /**
     * @param {paper.Item | null} item
     * @returns {paper.Item | null} Root group for a dropped FOV ring, if any.
     */
    _findFovDroppedRingGroup(item) {
        let cur = item;
        while (cur) {
            if (cur.name === 'fovDroppedRing' || cur.data?.kind === 'fieldOfView') {
                return cur;
            }
            cur = cur.parent;
        }
        return null;
    }

    /**
     * Same tolerance convention as {@link ToolBase#getTolerance} (screen px → project units).
     * @param {paper.View} view
     * @param {paper.Layer} layer
     */
    _fovHitTolerance(view, layer) {
        const scale = layer.scaling?.x || 1;
        return 5 / scale / view.getZoom();
    }

    _dismissFovContextMenu() {
        if (this._fovContextMenuEl) {
            this._fovContextMenuEl.remove();
            this._fovContextMenuEl = null;
        }
        document.removeEventListener('pointerdown', this._fovContextMenuPointerDismiss, true);
        window.removeEventListener('keydown', this._fovContextMenuKeyDismiss, true);
        window.removeEventListener('blur', this._fovContextMenuBlurDismiss);
        this.viewer.container?.removeEventListener('scroll', this._fovContextMenuScrollDismiss, true);
    }

    /**
     * @param {number} clientX
     * @param {number} clientY
     * @param {paper.Item} ringGroup
     */
    _openFovContextMenu(clientX, clientY, ringGroup) {
        this._dismissFovContextMenu();

        const wrap = document.createElement('div');
        wrap.className = 'fov-context-menu';
        wrap.setAttribute('role', 'menu');
        wrap.style.cssText =
            'position:fixed;z-index:100000;background:#fff;border:1px solid rgba(0,0,0,0.15);' +
            'border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.18);min-width:12rem;padding:4px 0;' +
            'font:13px system-ui,-apple-system,sans-serif;';

        const rowStyle =
            'display:block;width:100%;text-align:left;padding:8px 12px;border:none;background:transparent;' +
            'cursor:pointer;font:inherit;color:#111;';
        const hoverOn = (b) => {
            b.style.background = 'rgba(0,0,0,0.06)';
        };
        const hoverOff = (b) => {
            b.style.background = 'transparent';
        };

        const fmt = (n, digits = 6) => {
            const x = Number(n);
            if (!Number.isFinite(x)) return '';
            const s = x.toFixed(digits);
            return s.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
        };

        const info = document.createElement('div');
        info.style.cssText =
            'padding:8px 12px 6px 12px;color:rgba(0,0,0,0.70);font:12px system-ui,-apple-system,sans-serif;line-height:1.25;';

        const dPx = Number(ringGroup?.data?.diameterPx);
        const dPhys = Number(ringGroup?.data?.diameterPhysical);
        const unit = ringGroup?.data?.unit === 'um' ? 'um' : 'mm';

        const lines = [];
        if (Number.isFinite(dPhys) && dPhys > 0) {
            lines.push(`Diameter: ${fmt(dPhys, unit === 'mm' ? 4 : 2)} ${unit}${Number.isFinite(dPx) && dPx > 0 ? ` (${fmt(dPx, 2)} px)` : ''}`);
            const aMm2 = areaMm2FromDiameterPhysical(dPhys, unit);
            if (aMm2 != null) {
                lines.push(`Area: ${fmt(aMm2, 8)} mm²`);
            }
        } else if (Number.isFinite(dPx) && dPx > 0) {
            lines.push(`Diameter: ${fmt(dPx, 2)} px`);
        }

        if (lines.length) {
            info.textContent = lines.join('\n');
            info.style.whiteSpace = 'pre-line';
            const sep = document.createElement('div');
            sep.style.cssText = 'height:1px;background:rgba(0,0,0,0.08);margin:4px 0;';
            wrap.append(info, sep);
        }

        const btnThis = document.createElement('button');
        btnThis.type = 'button';
        btnThis.textContent = 'Delete this field of view';
        btnThis.style.cssText = rowStyle;
        btnThis.addEventListener('mouseenter', () => hoverOn(btnThis));
        btnThis.addEventListener('mouseleave', () => hoverOff(btnThis));
        btnThis.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            ringGroup.remove();
            this.notifyFovRingGeometryChanged();
            this._dismissFovContextMenu();
        });

        const btnAll = document.createElement('button');
        btnAll.type = 'button';
        btnAll.textContent = 'Delete all';
        btnAll.style.cssText = rowStyle;
        btnAll.addEventListener('mouseenter', () => hoverOn(btnAll));
        btnAll.addEventListener('mouseleave', () => hoverOff(btnAll));
        btnAll.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            this.clearAllDroppedFieldOfViews();
            this._dismissFovContextMenu();
        });

        wrap.append(btnThis, btnAll);
        document.body.appendChild(wrap);

        const rect = wrap.getBoundingClientRect();
        const vw = window.innerWidth;
        const vh = window.innerHeight;
        let left = clientX;
        let top = clientY;
        if (left + rect.width > vw - 8) left = vw - rect.width - 8;
        if (top + rect.height > vh - 8) top = vh - rect.height - 8;
        if (left < 8) left = 8;
        if (top < 8) top = 8;
        wrap.style.left = `${left}px`;
        wrap.style.top = `${top}px`;

        this._fovContextMenuEl = wrap;

        const arm = () => {
            document.addEventListener('pointerdown', this._fovContextMenuPointerDismiss, true);
            window.addEventListener('keydown', this._fovContextMenuKeyDismiss, true);
            window.addEventListener('blur', this._fovContextMenuBlurDismiss);
            this.viewer.container?.addEventListener('scroll', this._fovContextMenuScrollDismiss, true);
        };
        setTimeout(arm, 0);
    }

    /**
     * @param {MouseEvent} ev
     */
    _onFovCanvasContextMenu(ev) {
        this._dismissFovContextMenu();

        const view = this.overlay.paperScope.view;
        const layer = this.getDropTargetLayer();
        const viewEl = view.element;
        if (!layer || !viewEl.contains(ev.target)) return;

        const br = viewEl.getBoundingClientRect();
        const ox = ev.clientX - br.left;
        const oy = ev.clientY - br.top;
        const pt = view.viewToProject(new paper.Point(ox, oy));
        const tolerance = this._fovHitTolerance(view, layer);
        const hit = layer.hitTest(pt, {
            fill: true,
            stroke: true,
            segments: true,
            tolerance,
            match: (hr) => Boolean(this._findFovDroppedRingGroup(hr.item)),
        });
        const ring = hit ? this._findFovDroppedRingGroup(hit.item) : null;
        if (!ring) return;

        ev.preventDefault();
        ev.stopPropagation();
        this._openFovContextMenu(ev.clientX, ev.clientY, ring);
    }

    /**
     * @param {OpenSeadragon.Point} centerImg
     * @param {number} diameterPx
     */
    buildDropItemData(centerImg, diameterPx) {
        const mpp = this._getMppOrNull();
        const base = {
            kind: 'fieldOfView',
            schemaVersion: 1,
            centerImage: { x: centerImg.x, y: centerImg.y },
            diameterPx,
        };
        if (!mpp) {
            return base;
        }
        return {
            ...base,
            unit: this.settings.unit,
            diameterPhysical: Number(this.settings.diameter),
            mpp: { x: mpp.x, y: mpp.y },
        };
    }

    _scheduleSaveSettings() {
        this._saveSettingsTimeout && clearTimeout(this._saveSettingsTimeout);
        this._saveSettingsTimeout = setTimeout(() => {
            this._saveSettingsTimeout = null;
            saveFieldOfViewSettings(this.settings);
        }, 200);
    }

    _getMppOrNull() {
        try {
            return mppFromTiledImage(this._resolveActiveTiledImageOrThrow());
        } catch {
            return null;
        }
    }

    /**
     * Same draft parsing pattern as screenshot overlay: trim, require finite number > 0.
     * @param {string} text
     * @returns {{ ok: true, value: number } | { ok: false }}
     */
    _parsePositiveFloatDraft(text) {
        const s = String(text ?? '').trim();
        if (s === '') return { ok: false };
        const n = Number(s);
        if (!Number.isFinite(n) || n <= 0) return { ok: false };
        return { ok: true, value: n };
    }

    /** @param {Element | null} el */
    _isFocused(el) {
        return Boolean(el && typeof document !== 'undefined' && document.activeElement === el);
    }

    /**
     * Avoid clobbering in-progress typing when mirroring the paired numeric field (screenshot overlay pattern).
     * @param {HTMLInputElement | null} input
     * @param {string} valueString
     */
    _setIfNotFocused(input, valueString) {
        if (!input) return;
        if (this._isFocused(input)) return;
        input.value = String(valueString ?? '');
    }

    /** @param {number} a */
    _formatAreaMm2ForInput(a) {
        if (!Number.isFinite(a) || a <= 0) return '';
        const s = a.toFixed(8);
        return s.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.0+$/, '');
    }

    /**
     * Read diameter / area inputs and commit into `this.settings` (used before Place if user did not blur).
     */
    _commitFovDialogInputsFromDom() {
        if (!this.dialog) return;
        const dIn = this.dialog.querySelector('.fov-diameter');
        const aIn = this.dialog.querySelector('.fov-area-mm2');
        const active = typeof document !== 'undefined' ? document.activeElement : null;
        if (active === aIn && aIn) {
            const pa = this._parsePositiveFloatDraft(aIn.value);
            if (pa.ok) {
                const dPhys = diameterPhysicalFromAreaMm2(pa.value, this.settings.unit);
                if (dPhys != null) {
                    this.settings.diameter = dPhys;
                }
            }
        } else if (dIn) {
            const pd = this._parsePositiveFloatDraft(dIn.value);
            if (pd.ok) {
                this.settings.diameter = pd.value;
            }
        }
        this.settings = normalizeFieldOfViewSettings(this.settings);
        this._scheduleSaveSettings();
    }

    _setState(next) {
        this._state = next;
        if (!this.dialog) return;
        const el = this.dialog;
        const label = el.querySelector('.fov-compact-label');
        if (next === 'inactive') {
            el.classList.add('hidden');
            el.classList.remove('fov-dialog--compact');
            return;
        }
        el.classList.remove('hidden');
        if (next === 'placing' || next === 'editing') {
            el.classList.add('fov-dialog--compact');
            if (label) {
                label.textContent = next === 'placing' ? 'Placing…' : 'Editing…';
            }
            return;
        }
        el.classList.remove('fov-dialog--compact');
        if (next === 'config') {
            this._syncDialogFromSettings();
        }
    }

    _syncDialogFromSettings() {
        if (!this.dialog) return;
        this.settings = normalizeFieldOfViewSettings(this.settings);
        const el = this.dialog;

        const diameterInput = el.querySelector('.fov-diameter');
        const areaInput = el.querySelector('.fov-area-mm2');
        const unitSel = el.querySelector('.fov-unit');
        this._setIfNotFocused(diameterInput, String(this.settings.diameter));
        const aMm2 = areaMm2FromDiameterPhysical(this.settings.diameter, this.settings.unit);
        if (aMm2 != null) {
            this._setIfNotFocused(areaInput, this._formatAreaMm2ForInput(aMm2));
        } else if (areaInput && !this._isFocused(areaInput)) {
            areaInput.value = '';
        }
        if (unitSel) unitSel.value = this.settings.unit;

        const mpp = this._getMppOrNull();
        const warn = el.querySelector('.fov-mpp-warning');
        const placeBtn = el.querySelector('.fov-place');
        const clearBtn = el.querySelector('.fov-clear-all');
        const adjustBtn = el.querySelector('.fov-adjust-circles');
        const multi = (this.viewer.world?.getItemCount?.() ?? 0) > 1;
        const none = (this.viewer.world?.getItemCount?.() ?? 0) === 0;
        const ringCount = this.countDroppedFovRings();
        const canPlace = !none && !multi && Boolean(mpp);

        if (none || multi) {
            if (warn) {
                warn.textContent = none
                    ? 'No image in the viewer.'
                    : 'Multiple images are open; field of view supports exactly one image.';
                warn.classList.remove('hidden');
            }
            if (placeBtn) placeBtn.disabled = true;
            if (clearBtn) clearBtn.disabled = true;
            if (adjustBtn) adjustBtn.disabled = true;
        } else if (!mpp) {
            if (warn) {
                warn.textContent = 'Requires mpp metadata (µm/px) to convert physical size to pixels.';
                warn.classList.remove('hidden');
            }
            if (placeBtn) placeBtn.disabled = true;
            if (clearBtn) clearBtn.disabled = false;
            if (adjustBtn) adjustBtn.disabled = true;
        } else {
            if (warn) {
                warn.textContent = '';
                warn.classList.add('hidden');
            }
            if (placeBtn) placeBtn.disabled = false;
            if (clearBtn) clearBtn.disabled = false;
            if (adjustBtn) adjustBtn.disabled = !canPlace || ringCount === 0;
        }
    }

    _diameterPixelsFromSettings() {
        const mpp = this._getMppOrNull();
        if (!mpp) return null;
        return diameterPhysicalToBasePixels(mpp, this.settings?.diameter, this.settings.unit);
    }

    /** Keeps full-dialog action buttons in sync after a ring is dropped without leaving compact mode. */
    notifyFovRingGeometryChanged() {
        this._syncDialogFromSettings();
    }

    _makeDialog() {
        const html = `<div class="fov-dialog hidden">
            <div class="fov-dialog-full">
                <div class="fov-header">
                    <div class="fov-title">Field of view</div>
                    <button class="close fov-close" type="button" aria-label="Close">×</button>
                </div>
                <div class="fov-body">
                    <div class="fov-row">
                        <span class="fov-muted">Diameter</span>
                        <input class="fov-diameter" type="number" step="any" inputmode="decimal" />
                        <select class="fov-unit">
                            <option value="mm">mm</option>
                            <option value="um">µm</option>
                        </select>
                    </div>
                    <div class="fov-row">
                        <span class="fov-muted">Area</span>
                        <input class="fov-area-mm2" type="number" step="any" inputmode="decimal" />
                        <span class="fov-muted">mm²</span>
                    </div>
                    <div class="fov-row fov-mpp-warning fov-muted hidden"></div>
                    <div class="fov-actions">
                        <button class="fov-clear-all" type="button">Clear all</button>
                        <button class="fov-adjust-circles" type="button">Adjust circles</button>
                        <button class="fov-place fov-primary" type="button">Place field of view</button>
                    </div>
                    <div class="fov-hint fov-muted">Esc closes. Place: preview then click to drop. Adjust: drag rings; right-click for menu.</div>
                </div>
            </div>
            <div class="fov-compact-bar" aria-label="Field of view mode">
                <span class="fov-compact-label"></span>
                <span class="fov-compact-spacer"></span>
                <button class="fov-compact-plus" type="button" title="Return to full settings">+</button>
                <button class="fov-compact-exit" type="button">Exit</button>
            </div>
        </div>`;

        const css = `<style data-type="fieldofview-overlay">
            .fov-dialog{
                position: absolute;
                top: 50%;
                left: 50%;
                transform: translate(-50%, -50%);
                box-sizing: border-box;
                width: min(360px, calc(100% - 24px));
                max-height: calc(100% - 24px);
                overflow: auto;
                background: #fff;
                color: #111;
                border: 1px solid rgba(0,0,0,0.14);
                border-radius: 10px;
                box-shadow: 0 14px 40px rgba(0,0,0,0.22);
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
                font-size: 13px;
                line-height: 1.25;
            }
            .fov-dialog.hidden{ display:none; }
            .hidden{ display:none !important; }
            .fov-dialog.fov-dialog--compact{
                position: fixed;
                top: 12px;
                left: 50%;
                transform: translateX(-50%);
                width: auto;
                min-width: 260px;
                max-width: min(480px, calc(100% - 24px));
                max-height: none;
                overflow: visible;
                z-index: 50;
            }
            .fov-dialog.fov-dialog--compact .fov-dialog-full{ display: none !important; }
            .fov-dialog:not(.fov-dialog--compact) .fov-compact-bar{ display: none !important; }
            .fov-compact-bar{
                display: flex;
                align-items: center;
                gap: 10px;
                padding: 8px 12px;
                flex-wrap: wrap;
            }
            .fov-compact-label{
                font-weight: 600;
                white-space: nowrap;
            }
            .fov-compact-spacer{ flex: 1 1 auto; min-width: 8px; }
            .fov-header{
                display:flex;
                align-items:center;
                justify-content:space-between;
                padding: 10px 12px;
                border-bottom: 1px solid rgba(0,0,0,0.08);
                position: sticky;
                top: 0;
                background: #fff;
                z-index: 1;
            }
            .fov-title{ font-weight: 600; font-size: 14px; }
            .fov-close{
                border: none;
                background: transparent;
                font-size: 18px;
                line-height: 1;
                padding: 2px 6px;
                cursor: pointer;
                color: rgba(0,0,0,0.6);
            }
            .fov-close:hover{ color: rgba(0,0,0,0.9); }
            .fov-body{ padding: 10px 12px; }
            .fov-row{
                display:flex;
                gap: 8px;
                align-items:center;
                flex-wrap: wrap;
                margin: 2px 0;
            }
            .fov-muted{ color: rgba(0,0,0,0.60); }
            .fov-actions{ margin-top: 10px; display:flex; justify-content:flex-end; align-items:center; gap: 8px; flex-wrap: wrap; }
            .fov-hint{ margin-top: 8px; }
            button{
                font: inherit;
                padding: 5px 10px;
                border: 1px solid rgba(0,0,0,0.18);
                background: #fff;
                border-radius: 8px;
                cursor: pointer;
            }
            button:hover{ border-color: rgba(0,0,0,0.30); }
            button.fov-primary{
                background: #111;
                color: #fff;
                border-color: #111;
            }
            button.fov-primary:hover{ background:#000; border-color:#000; }
            input[type=number]{
                width: 7.5em;
                padding: 4px 6px;
                border: 1px solid rgba(0,0,0,0.18);
                border-radius: 8px;
                font: inherit;
            }
            select{
                padding: 4px 6px;
                border: 1px solid rgba(0,0,0,0.18);
                border-radius: 8px;
                font: inherit;
                background: #fff;
            }
        </style>`;

        if (!document.querySelector('style[data-type="fieldofview-overlay"]')) {
            document.querySelector('head')?.appendChild(domObjectFromHTML(css));
        }

        const el = domObjectFromHTML(html);
        this.viewer.container.appendChild(el);
        el.addEventListener('mousemove', (ev) => ev.stopPropagation());
        el.querySelectorAll('.close').forEach((e) => e.addEventListener('click', () => this.deactivate()));

        const diameterInput = el.querySelector('.fov-diameter');
        const areaInput = el.querySelector('.fov-area-mm2');

        diameterInput?.addEventListener('blur', () => {
            const parsed = this._parsePositiveFloatDraft(diameterInput.value);
            if (parsed.ok) {
                this.settings.diameter = parsed.value;
                this.settings = normalizeFieldOfViewSettings(this.settings);
                this._scheduleSaveSettings();
            }
            this._syncDialogFromSettings();
            this.tool.setDiameterPixels(this._diameterPixelsFromSettings());
        });
        diameterInput?.addEventListener('input', () => {
            const parsed = this._parsePositiveFloatDraft(diameterInput.value);
            if (parsed.ok) {
                const aMm2 = areaMm2FromDiameterPhysical(parsed.value, this.settings.unit);
                if (aMm2 != null) {
                    this._setIfNotFocused(areaInput, this._formatAreaMm2ForInput(aMm2));
                }
                const mpp = this._getMppOrNull();
                if (mpp) {
                    const px = diameterPhysicalToBasePixels(mpp, parsed.value, this.settings.unit);
                    this.tool.setDiameterPixels(px);
                }
            }
        });

        areaInput?.addEventListener('blur', () => {
            const parsed = this._parsePositiveFloatDraft(areaInput.value);
            if (parsed.ok) {
                const dPhys = diameterPhysicalFromAreaMm2(parsed.value, this.settings.unit);
                if (dPhys != null) {
                    this.settings.diameter = dPhys;
                    this.settings = normalizeFieldOfViewSettings(this.settings);
                    this._scheduleSaveSettings();
                }
            }
            this._syncDialogFromSettings();
            this.tool.setDiameterPixels(this._diameterPixelsFromSettings());
        });
        areaInput?.addEventListener('input', () => {
            const parsed = this._parsePositiveFloatDraft(areaInput.value);
            if (parsed.ok) {
                const dPhys = diameterPhysicalFromAreaMm2(parsed.value, this.settings.unit);
                if (dPhys != null) {
                    this._setIfNotFocused(diameterInput, String(dPhys));
                    const mpp = this._getMppOrNull();
                    if (mpp) {
                        const px = diameterPhysicalToBasePixels(mpp, dPhys, this.settings.unit);
                        this.tool.setDiameterPixels(px);
                    }
                }
            }
        });

        const unitSel = el.querySelector('.fov-unit');
        unitSel?.addEventListener('change', () => {
            this.settings.unit = unitSel.value === 'um' ? 'um' : 'mm';
            this.settings = normalizeFieldOfViewSettings(this.settings);
            this._scheduleSaveSettings();
            this._syncDialogFromSettings();
            this.tool.setDiameterPixels(this._diameterPixelsFromSettings());
        });

        const clearBtn = el.querySelector('.fov-clear-all');
        clearBtn?.addEventListener('click', () => {
            this.clearAllDroppedFieldOfViews();
        });

        const placeBtn = el.querySelector('.fov-place');
        placeBtn?.addEventListener('click', () => {
            this._commitFovDialogInputsFromDom();
            this._syncDialogFromSettings();
            const px = this._diameterPixelsFromSettings();
            fovOverlayLog('Place clicked', {
                diameterPx: px,
                unit: this.settings.unit,
                diameterPhysical: this.settings.diameter,
                mpp: this._getMppOrNull(),
            });
            if (!px) return;
            this.tool.setDiameterPixels(px);
            this.tool.setMode('placing');
            this._setState('placing');
        });

        const adjustBtn = el.querySelector('.fov-adjust-circles');
        adjustBtn?.addEventListener('click', () => {
            this._commitFovDialogInputsFromDom();
            this._syncDialogFromSettings();
            if (adjustBtn.disabled) return;
            const px = this._diameterPixelsFromSettings();
            if (!px) return;
            this.tool.setDiameterPixels(px);
            this.tool.setMode('editing');
            this._setState('editing');
        });

        el.querySelector('.fov-compact-plus')?.addEventListener('click', () => {
            this.tool.clearEditUiState();
            this.tool.setMode('idle');
            this._setState('config');
        });
        el.querySelector('.fov-compact-exit')?.addEventListener('click', () => {
            this.deactivate();
        });

        this.dialog = el;
        this._syncDialogFromSettings();
        this.tool.setDiameterPixels(this._diameterPixelsFromSettings());
    }
}

export { FieldOfViewOverlay };