@itwin/core-frontend
Version:
iTwin.js frontend components
411 lines • 21.6 kB
JavaScript
"use strict";
/*---------------------------------------------------------------------------------------------
* 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
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.MarkerSet = exports.Cluster = exports.Marker = void 0;
const core_bentley_1 = require("@itwin/core-bentley");
const core_geometry_1 = require("@itwin/core-geometry");
const FrontendLoggerCategory_1 = require("./common/FrontendLoggerCategory");
const ImageUtil_1 = require("./common/ImageUtil");
const ViewRect_1 = require("./common/ViewRect");
const IModelApp_1 = require("./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 = core_geometry_1.Vector3d.create();
for (const corner of corners) {
core_geometry_1.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
*/
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 core_geometry_1.Point3d();
/** The current rectangle for the marker, in view coordinates (pixels). This value will be updated by calls to [[setPosition]]. */
rect = new ViewRect_1.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_1.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 = core_geometry_1.Range1d.fromJSON(range);
this._scaleFactor = core_geometry_1.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 = core_geometry_1.Point3d.createFrom(worldLocation);
this.size = core_geometry_1.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 = core_geometry_1.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 core_geometry_1.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"}`;
core_bentley_1.Logger.logError(`${FrontendLoggerCategory_1.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((0, ImageUtil_1.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 = core_geometry_1.Geometry.clamp(range.high - (pt4.w / minScaleViewW) * range.length(), .4, 2.0);
else
scale = core_geometry_1.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);
}
}
exports.Marker = Marker;
/** 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
*/
class Cluster {
markers;
clusterMarker;
constructor(markers) {
(0, core_bentley_1.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 = core_geometry_1.Point3d.createZero();
if (this.markers.length > 0) {
this.markers.forEach((marker) => location.addInPlace(marker.worldLocation));
location.scaleInPlace(1 / this.markers.length);
}
return location;
}
}
exports.Cluster = Cluster;
/** 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
*/
class MarkerSet {
_viewport;
/** @internal */
_entries = []; // this is an array that holds either Markers or a cluster of markers.
/** @internal */
_worldToViewMap = core_geometry_1.Matrix4d.createZero();
/** @internal */
_minScaleViewW;
_markers = new core_bentley_1.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_1.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.
}
}
}
}
exports.MarkerSet = MarkerSet;
//# sourceMappingURL=Marker.js.map