UNPKG

@itwin/core-frontend

Version:
405 lines • 21.1 kB
/*--------------------------------------------------------------------------------------------- * 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, Logger, ObservableSet } from "@itwin/core-bentley"; import { Geometry, Matrix4d, Point2d, Point3d, Range1d, Vector3d } from "@itwin/core-geometry"; 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; 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) 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 = 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