/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
/** @packageDocumentation
 * @module Views
 */
import { assert, expectDefined, Logger, ObservableSet } from "@itwin/core-bentley";
import { Geometry, Matrix4d, Point2d, Point3d, Range1d, Vector3d } from "@itwin/core-geometry";
import { Hilite } from "@itwin/core-common";
import { FrontendLoggerCategory } from "./common/FrontendLoggerCategory";
import { imageElementFromUrl } from "./common/ImageUtil";
import { ViewRect } from "./common/ViewRect";
import { IModelApp } from "./IModelApp";
function getMinScaleViewW(vp) {
    let zHigh;
    const origin = vp.view.getCenter();
    const direction = vp.view.getZVector();
    direction.scaleInPlace(-1);
    const corners = vp.view.iModel.projectExtents.corners();
    const delta = Vector3d.create();
    for (const corner of corners) {
        Vector3d.createStartEnd(origin, corner, delta);
        const projection = delta.dotProduct(direction);
        if (undefined === zHigh || projection > zHigh)
            zHigh = projection;
    }
    if (undefined === zHigh)
        return 0.0;
    origin.plusScaled(direction, zHigh, origin);
    return vp.worldToView4d(origin).w;
}
/** A Marker is a [[CanvasDecoration]], whose position follows a fixed location in world space.
 * Markers draw on top of all scene graphics, and show visual cues about locations of interest.
 * @see [Markers]($docs/learning/frontend/Markers)
 * @public
 * @extensions
 */
export class Marker {
    _scaleFactor;
    _scaleFactorRange;
    /** Whether this marker is currently enabled. If false, this Marker is not displayed. */
    visible = true;
    /** Whether this marker is currently hilited or not. */
    _isHilited = false;
    /** The color for the shadowBlur when this Marker is hilited */
    _hiliteColor;
    /** The location of this Marker in world coordinates. */
    worldLocation;
    /** The size of this Marker, in pixels. */
    size;
    /** The current position for the marker, in view coordinates (pixels). This value will be updated by calls to [[setPosition]]. */
    position = new Point3d();
    /** The current rectangle for the marker, in view coordinates (pixels). This value will be updated by calls to [[setPosition]]. */
    rect = new ViewRect();
    /** An image to draw for this Marker. If undefined, no image is shown. See https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage.  */
    image;
    /** The offset for [[image]], in pixels, from the *center* of this Marker. If undefined, (0,0). */
    imageOffset;
    /** The size of [[image]], in pixels. If undefined, use [[size]]. */
    imageSize;
    /** A text Label for this Marker. If undefined, no label is displayed. */
    label;
    /** The offset for [[label]], in pixels, from the *center* of this Marker. If undefined, (0,0). */
    labelOffset;
    /** The maximum with for [[label]], in pixels. If undefined label will not be condensed or use a smaller font size. */
    labelMaxWidth;
    /** The color for [[label]]. See  https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle. If undefined, "white". */
    labelColor;
    /** The text alignment for [[label]]. See https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textAlign. If undefined, "center" */
    labelAlign;
    /** The text baseline for [[label]]. See https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textBaseline. If undefined, "middle" */
    labelBaseline;
    /** The font for [[label]]. See https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/font. */
    labelFont;
    /** The title string, or HTMLElement, to show (only) in the ToolTip when the pointer is over this Marker. See [[NotificationManager.openToolTip]] */
    title;
    /** The ToolTipOptions to use for [[title]]. */
    tooltipOptions;
    /** An Optional (unique) HTMLElement to display with this Marker. Generally, HTMLElements are more expensive than
     * images and labels, since they are added/removed from the DOM every frame. But, some types of markers are more convenient to construct
     * as HTMLElements, and if there aren't too many of them performance is fine.
     * @note HTMLElements may only appear in the DOM one time. Therefore, they *may not be shared* by more than one Marker.
     * You must ensure that each marker has its own HTMLElement. For this reason, you should probably only use HTMLElements in Markers if
     * each one is meant to be unique. For shared content, use images.
     */
    htmlElement;
    /** Return true to display [[image]], if present. */
    get wantImage() { return true; }
    /** Called when the mouse pointer enters this Marker. */
    onMouseEnter(ev) {
        this._isHilited = true;
        this._hiliteColor = ev.viewport?.hilite.color ?? new Hilite.Settings().color;
        IModelApp.accuSnap.clear();
    }
    /** Called when the mouse pointer leaves this Marker. */
    onMouseLeave() { this._isHilited = false; }
    /** Called when the mouse pointer moves over this Marker */
    onMouseMove(ev) {
        if (this.title)
            expectDefined(ev.viewport).openToolTip(this.title, ev.viewPoint, this.tooltipOptions);
    }
    /** Determine whether the point is within this Marker.  */
    pick(pt) { return this.rect.containsPoint(pt); }
    /** Establish a range of scale factors to increases and decrease the size of this Marker based on its distance from the camera.
     * @param range The minimum and maximum scale factors to be applied to the size of this Marker based on its distance from the camera. `range.Low` is the scale factor
     * for Markers at the back of the view frustum and `range.high` is the scale factor at the front of the view frustum.
     * @note Marker size scaling is only applied in views with the camera enabled. It has no effect on orthographic views.
     */
    setScaleFactor(range) {
        this._scaleFactorRange = Range1d.fromJSON(range);
        this._scaleFactor = Point2d.create(1, 1);
    }
    /** Constructor for Marker
     * @param worldLocation The location of this Marker in world coordinates.
     * @param size The size of this Marker in pixels.
     */
    constructor(worldLocation, size) {
        this.worldLocation = Point3d.createFrom(worldLocation);
        this.size = Point2d.createFrom(size);
    }
    /** Make a new Marker at the same position and size as this Marker.
     * The new Marker will share the world location and size, but will be otherwise blank.
     */
    static makeFrom(other, ...args) {
        const out = new this(other.worldLocation, other.size, ...args);
        out.rect.setFrom(other.rect);
        out.position.setFrom(other.position);
        if (other._scaleFactor)
            out._scaleFactor = Point2d.createFrom(other._scaleFactor);
        out._scaleFactorRange = other._scaleFactorRange;
        return out;
    }
    /** When a Marker is displayed in its hilited state, this method is called first. If it returns true, no further action is taken.
     * Otherwise the Marker's normal drawing operations are also called. By default, this method adds a shadowBlur effect and increases
     * the size of the Marker by 25%.
     * @return true to stop drawing this Marker
     */
    drawHilited(ctx) {
        ctx.shadowBlur = 30;
        ctx.shadowColor = this._hiliteColor ? this._hiliteColor.toHexString() : "white";
        ctx.scale(1.25, 1.25);
        return false;
    }
    /** Called during frame rendering to display this Marker onto the supplied context. */
    drawDecoration(ctx) {
        if (this._isHilited && this.drawHilited(ctx))
            return;
        if (this._scaleFactor !== undefined)
            ctx.scale(this._scaleFactor.x, this._scaleFactor.y);
        // first call the "drawFunc" if defined. This means it will be below the image and label if they overlap
        if (undefined !== this.drawFunc)
            this.drawFunc(ctx);
        // next draw the image, if defined and desired
        if (this.wantImage && this.image !== undefined) {
            const size = this.imageSize ? this.imageSize : this.size;
            const offset = new Point2d(size.x / 2, size.y / 2);
            if (this.imageOffset)
                offset.plus(this.imageOffset, offset);
            ctx.drawImage(this.image, -offset.x, -offset.y, size.x, size.y);
        }
        // lastly, draw the label, if defined. This puts it on top of all other graphics for this Marker.
        if (this.label !== undefined) {
            ctx.textAlign = this.labelAlign ? this.labelAlign : "center";
            ctx.textBaseline = this.labelBaseline ? this.labelBaseline : "middle";
            ctx.font = this.labelFont ? this.labelFont : "14px sans-serif";
            ctx.fillStyle = this.labelColor ? this.labelColor : "white";
            ctx.fillText(this.label, this.labelOffset ? -this.labelOffset.x : 0, this.labelOffset ? -this.labelOffset.y : 0, this.labelMaxWidth);
        }
    }
    /** Set the [[image]] for this marker.
     * @param image Either a [[MarkerImage]] or a Promise for a [[MarkerImage]]. If a Promise is supplied, the [[image]] member is set
     * when the Promise resolves.
     */
    setImage(image) {
        if (image instanceof Promise) {
            image.then((resolvedImage) => this.image = resolvedImage).catch((err) => {
                const target = err.target;
                const msg = `Could not load image ${target && target.src ? target.src : "unknown"}`;
                Logger.logError(`${FrontendLoggerCategory.Package}.markers`, msg);
                console.log(msg); // eslint-disable-line no-console
            });
        }
        else
            this.image = image;
    }
    /** Set the image for this Marker from a URL. */
    setImageUrl(url) { this.setImage(imageElementFromUrl(url)); }
    /** Set the position (in pixels) for this Marker in the supplied Viewport, based on its worldLocation.
     * @param markerSet The MarkerSet if this Marker is included in a set.
     * @return true if the Marker is visible and its new position is inside the Viewport.
     */
    setPosition(vp, markerSet) {
        if (!this.visible) // if we're turned off, skip
            return false;
        const pt4 = vp.worldToView4d(this.worldLocation);
        if (pt4.w > 1.0 || pt4.w < 1.0e-6) // outside of frustum or too close to eye.
            return false;
        pt4.realPoint(this.position);
        if (!vp.viewRect.containsPoint(this.position))
            return false; // outside this viewport rect
        const origin = this.position;
        const sizeX = this.size.x / 2;
        const sizeY = this.size.y / 2;
        this.rect.init(origin.x - sizeX, origin.y - sizeY, origin.x + sizeX, origin.y + sizeY);
        // if there's a scale factor active, calculate it now.
        if (this._scaleFactor && this._scaleFactorRange) {
            let scale = 1.0;
            if (vp.isCameraOn) {
                const range = this._scaleFactorRange;
                const minScaleViewW = (undefined !== markerSet ? markerSet.getMinScaleViewW(vp) : getMinScaleViewW(vp));
                if (minScaleViewW > 0.0)
                    scale = Geometry.clamp(range.high - (pt4.w / minScaleViewW) * range.length(), .4, 2.0);
                else
                    scale = Geometry.clamp(range.low + ((1 - pt4.w) * range.length()), .4, 2.0);
                this.rect.scaleAboutCenter(scale, scale);
            }
            this._scaleFactor.set(scale, scale);
        }
        return true;
    }
    /** Position the HTMLElement for this Marker relative to the Marker's position in the view.
     * The default implementation centers the HTMLElement (using its boundingClientRect) on the Marker.
     * Override this method to provide an alternative positioning approach.
     */
    positionHtml() {
        const html = expectDefined(this.htmlElement);
        const style = html.style;
        style.position = "absolute";
        const size = html.getBoundingClientRect(); // Note: only call this *after* setting position = absolute
        const markerPos = this.position;
        style.left = `${markerPos.x - (size.width / 2)}px`;
        style.top = `${markerPos.y - (size.height / 2)}px`;
    }
    /** Add this Marker to the supplied DecorateContext. */
    addMarker(context) {
        context.addCanvasDecoration(this);
        if (undefined !== this.htmlElement) {
            // add this Marker to the DOM
            context.addHtmlDecoration(this.htmlElement);
            this.positionHtml(); // always reposition it
        }
    }
    /** Set the position and add this Marker to the supplied DecorateContext, if it's visible.
     * This method should be called from your implementation of [[Decorator.decorate]]. It will set this Marker's position based on the
     * Viewport from the context, and add this this Marker to the supplied DecorateContext.
     * @param context The DecorateContext for the Marker
     */
    addDecoration(context) {
        if (this.setPosition(context.viewport))
            this.addMarker(context);
    }
}
/** A cluster of one or more Markers that overlap one another in the view. The cluster's screen position is taken from its first entry.
 * Clusters also have a Marker themselves, that represents the whole group. The cluster marker isn't created until all entries have been added.
 * @public
 * @extensions
 */
export class Cluster {
    markers;
    clusterMarker;
    constructor(markers) {
        assert(markers.length > 0);
        this.markers = markers;
    }
    get position() {
        return this.markers[0].position;
    }
    /**
     * Gets the location for the cluster
     * @returns The average of the cluster markers worldLocation.
     */
    getClusterLocation() {
        const location = Point3d.createZero();
        if (this.markers.length > 0) {
            this.markers.forEach((marker) => location.addInPlace(marker.worldLocation));
            location.scaleInPlace(1 / this.markers.length);
        }
        return location;
    }
}
/** A *set* of Markers that are logically related, such that they *cluster* when they overlap one another in screen space.
 * In that case, a *cluster marker* is drawn instead of the overlapping Markers.
 * @public
 * @extensions
 */
export class MarkerSet {
    _viewport;
    /** @internal */
    _entries = []; // this is an array that holds either Markers or a cluster of markers.
    /** @internal */
    _worldToViewMap = Matrix4d.createZero();
    /** @internal */
    _minScaleViewW;
    _markers = new ObservableSet();
    /** The minimum number of Markers that must overlap before they are clustered. Otherwise they are each drawn individually. Default is 1 (always create a cluster.) */
    minimumClusterSize = 1;
    /** The set of Markers in this MarkerSet. Add your [[Marker]]s into this. */
    get markers() { return this._markers; }
    /** The radius (in pixels) representing the distance between the screen X,Y positions of two Markers to be clustered. When less than or equal to 0 (the default), the radius is calculated based on the first visible marker imageSize/size. */
    clusterRadius = 0;
    /** Construct a new MarkerSet for a specific ScreenViewport.
     * @param viewport the ScreenViewport for this MarkerSet. If undefined, use [[IModelApp.viewManager.selectedView]]
     */
    constructor(viewport) {
        this._viewport = undefined === viewport ? IModelApp.viewManager.selectedView : viewport;
        const markDirty = () => this.markDirty();
        this._markers.onAdded.addListener(markDirty);
        this._markers.onDeleted.addListener(markDirty);
        this._markers.onCleared.addListener(markDirty);
    }
    /** The ScreenViewport of this MarkerSet. */
    get viewport() { return this._viewport; }
    /** Change the ScreenViewport for this MarkerSet.
     * After this call, the markers from this MarkerSet will only appear in the supplied ScreenViewport.
     * @beta
     */
    changeViewport(viewport) {
        this._viewport = viewport;
        this.markDirty();
    }
    /** Indicate that this MarkerSet has been changed and is now *dirty*.
     * This is necessary because [[addDecoration]] does not recreate the set of decoration graphics
     * if it can detect that the previously-created set remains valid.
     * The set becomes invalid when the view frustum changes, or the contents of [[markers]] changes.
     * If some other criterion affecting the graphics changes, invoke this method. This should not be necessary for most use cases.
     * @public
     */
    markDirty() {
        this._worldToViewMap.setZero();
    }
    /** Get weight value limit establishing the distance from camera for the back of view scale factor. */
    getMinScaleViewW(vp) {
        if (undefined === this._minScaleViewW)
            this._minScaleViewW = getMinScaleViewW(vp);
        return this._minScaleViewW;
    }
    /** This method should be called from [[Decorator.decorate]]. It will add this this MarkerSet to the supplied DecorateContext.
     * This method implements the logic that turns overlapping Markers into a Cluster.
     * @param context The DecorateContext for the Markers
     */
    addDecoration(context) {
        const vp = context.viewport;
        if (vp !== this._viewport)
            return; // not viewport of this MarkerSet, ignore it
        const entries = this._entries;
        // Don't recreate the entries array if the view hasn't changed. This is important for performance, but also necessary for hilite of
        // clusters (otherwise they're recreated continually and never hilited.) */
        if (!this._worldToViewMap.isAlmostEqual(vp.worldToViewMap.transform0)) {
            this._worldToViewMap.setFrom(vp.worldToViewMap.transform0);
            this._minScaleViewW = undefined; // Invalidate current value.
            entries.length = 0; // start over.
            let distSquared = this.clusterRadius * this.clusterRadius;
            // loop through all of the Markers in the MarkerSet.
            for (const marker of this.markers) {
                // establish the screen position for this marker. If it's not in view, setPosition returns false
                if (!marker.setPosition(vp, this))
                    continue;
                if (distSquared <= 0) {
                    const size = marker.imageSize ? marker.imageSize : marker.size;
                    const dist = Math.max(size.x, size.y) * 1.5;
                    distSquared = dist * dist;
                }
                let added = false;
                for (let i = 0; i < entries.length; ++i) { // loop through all of the currently visible markers/clusters
                    const entry = entries[i];
                    if (marker.position.distanceSquaredXY(entry.position) <= distSquared) {
                        added = true; // yes, we're going to save it as a Cluster
                        if (entry instanceof Cluster) { // is the entry already a Cluster?
                            entry.markers.push(marker); // yes, just add this to the existing cluster
                        }
                        else {
                            entries[i] = new Cluster([entry, marker]); // no, make a new Cluster holding both
                        }
                        break; // this Marker has been handled, we can stop looking for overlaps
                    }
                }
                if (!added)
                    entries.push(marker); // there was no overlap, save this Marker to be drawn
            }
        }
        // we now have an array of Markers and Clusters, add them to context
        for (const entry of entries) {
            if (entry instanceof Cluster) { // is this entry a Cluster?
                if (entry.markers.length <= this.minimumClusterSize) { // yes, does it have more than the minimum number of entries?
                    entry.markers.forEach((marker) => marker.addMarker(context)); // no, just draw all of its Markers
                }
                else {
                    // yes, get and draw the Marker for this Cluster
                    if (undefined === entry.clusterMarker) { // have we already created this cluster marker?
                        const clusterMarker = this.getClusterMarker(entry); // no, get it now.
                        // set the marker's position as getClusterMarker may not set it.
                        if (clusterMarker.rect.isNull)
                            clusterMarker.setPosition(vp, this);
                        entry.clusterMarker = clusterMarker;
                    }
                    entry.clusterMarker.addMarker(context);
                }
            }
            else {
                entry.addMarker(context); // entry is a non-overlapping Marker, draw it.
            }
        }
    }
}
//# sourceMappingURL=Marker.js.map