@itwin/measure-tools-react
Version:
Frontend framework and tools for measurements
510 lines • 24.1 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import { Angle, AxisIndex, LineString3d, Matrix3d, Point2d, Point3d, PolygonOps, Transform } from "@itwin/core-geometry";
import { ColorDef, Hilite } from "@itwin/core-common";
import { BeButton, IModelApp, SelectionMode, SelectionTool, } from "@itwin/core-frontend";
import { IconAlignment, StyleSet, TextOffsetType, WellKnownTextStyleType } from "./GraphicStyle.js";
var Visibility;
(function (Visibility) {
/** TextMarker is always visible. */
Visibility[Visibility["Visible"] = 0] = "Visible";
/** TextMarker is visible unless it's wider than maxWorldWidth. */
Visibility[Visibility["CollapseOversized"] = 1] = "CollapseOversized";
/** TextMarker is not displayed. */
Visibility[Visibility["Hidden"] = 2] = "Hidden";
})(Visibility || (Visibility = {}));
/**
* A TextMarker is used to display text that follows a fixed location in world space.
* @beta
*/
export class TextMarker {
get boxSize() { return this._boxSize; }
/** Constructor */
constructor(textLines, worldLocation, textDirection) {
/** The font for the text. See https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/font. */
this.textFont = "14px sans-serif";
/** Height of a text line, in percentage. Behaves like CSS's line-height property. */
this.textLineHeight = 1.2;
/** Fill style for text. See the `textColor` setter. */
this._textFillStyle = "rgb(0,0,0)";
this._textFillAlpha = 1.0;
this._textAngle = Angle.zero();
this._viewPosition = new Point3d();
/** Padding around the text, in pixels. */
this.boxPadding = 0;
/** When radius is positive, creates rounded corners. */
this.boxCornerRadius = 0.0;
/** Width of the black border around the box, in pixels. NOTE: only displayed if the box is visible. */
this.boxBorderWidth = 0;
/** Size of the box in pixels. Set by the last successful call to drawDecoration. */
this._boxSize = Point2d.createZero();
this._boxFillAlpha = 1.0;
this._iconPosition = IconAlignment.Center;
this._iconOffset = Point2d.createZero();
this._currPixelSize = 0;
this._flashViewports = new Set();
/** Whether this marker can be picked/consume mouse events. False by default. */
this.pickable = false;
/** Whether this marker is visible. Visible by default. */
this.visibility = Visibility.Visible;
/** See `Visibility.CollapseOversized` */
this.maxWorldWidth = 0.0;
this.textLines = textLines;
this.worldLocation = Point3d.createFrom(worldLocation);
this.textDirection = textDirection;
}
static createStyled(textLines, worldLocation, style, textDirection) {
const marker = new TextMarker(textLines, worldLocation, textDirection);
marker.applyStyle(style);
return marker;
}
/** Creates a TextMarker with the appropriate styling and offset parameters for a Hover box. */
static createHoverBox(textLines, worldLocation, styleSet) {
const marker = new TextMarker(textLines, worldLocation);
const styleTheme = styleSet || StyleSet.default;
const style = styleTheme.getTextStyle(WellKnownTextStyleType.HoverBox);
marker.applyStyle(style);
marker.offset = { x: 0.0, y: -0.8, type: TextOffsetType.Percentage };
return marker;
}
applyStyle(style) {
// If undefined, apply defaults
if (!style)
style = { textFont: "14px sans-serif" };
this.textFont = (style.textFont !== undefined) ? style.textFont : "14px sans-serif";
this.textLineHeight = (style.textLineHeight !== undefined) ? style.textLineHeight : 1.2;
this.textColor = (style.textColor !== undefined) ? ColorDef.fromTbgr(style.textColor) : ColorDef.create("rgba(0, 0, 0, 1)");
this.boxPadding = (style.boxPadding !== undefined) ? style.boxPadding : 0;
this.boxCornerRadius = (style.boxCornerRadius !== undefined) ? style.boxCornerRadius : 0;
this.boxBorderWidth = (style.boxBorderWidth !== undefined) ? style.boxBorderWidth : 0;
this._boxBorderColor = (style.boxBorderColor !== undefined) ? ColorDef.fromTbgr(style.boxBorderColor).toHexString() : undefined;
this.boxColor = (style.boxColor !== undefined) ? ColorDef.fromTbgr(style.boxColor) : undefined;
this.createIcon(style.icon);
}
createIcon(style) {
if (!style) {
this._iconHtmlElement = undefined;
this._iconPosition = IconAlignment.Center;
this._iconOffset.setZero();
return;
}
// eslint-disable-next-line deprecation/deprecation
const elem = document.createElement("i");
elem.className = `icon ${style.iconSpec}`;
this._iconPosition = (style.position !== undefined) ? style.position : IconAlignment.Center;
if (style.offset)
this._iconOffset.setFromJSON(style.offset);
else
this._iconOffset.setZero();
this._iconHtmlElement = elem;
if (style.size !== undefined)
elem.style.fontSize = `${style.size.toString()}px`;
if (style.iconColor !== undefined)
elem.style.color = ColorDef.fromTbgr(style.iconColor).toHexString();
if (style.padding !== undefined)
elem.style.padding = `${style.padding.toString()}px`;
if (style.bgColor !== undefined) {
elem.style.backgroundColor = ColorDef.fromTbgr(style.bgColor).toHexString();
if (style.bgCornerRadius !== undefined)
elem.style.borderRadius = `${style.bgCornerRadius.toString()}px`;
}
if (style.borderWidth !== undefined) {
elem.style.borderWidth = `${style.borderWidth.toString()}px`;
elem.style.borderStyle = "solid";
if (style.borderColor !== undefined)
elem.style.borderColor = ColorDef.fromTbgr(style.borderColor).toHexString();
}
}
positionIconHtml() {
if (this._iconHtmlElement === undefined)
return;
const html = this._iconHtmlElement;
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`;
}
moveIconHtml(boxWidth, boxHeight) {
// The idea is if the position of the icon is not at the center, then come up with a position somewhere
// on the outer edge of the box
if (this._iconPosition === IconAlignment.Center || this._iconHtmlElement === undefined)
return;
const xOffset = this.getXOffsetFromCenterInPixels(boxWidth);
const yOffset = this.getYOffsetFromCenterInPixels(boxHeight);
if (this.hiliteProps && this.hiliteProps.isHilited && undefined !== this.hiliteProps.scaleFactor) {
boxWidth = this.hiliteProps.scaleFactor * boxWidth;
boxHeight = this.hiliteProps.scaleFactor * boxHeight;
}
let x, y;
switch (this._iconPosition) {
case IconAlignment.TopLeft:
x = -0.5 * boxWidth + xOffset;
y = -0.5 * boxHeight + yOffset;
break;
case IconAlignment.Top:
x = xOffset;
y = -0.5 * boxHeight + yOffset;
break;
case IconAlignment.TopRight:
x = 0.5 * boxWidth + xOffset;
y = -0.5 * boxHeight + yOffset;
break;
case IconAlignment.Left:
x = -0.5 * boxWidth + xOffset;
y = yOffset;
break;
case IconAlignment.Right:
x = 0.5 * boxWidth + xOffset;
y = yOffset;
break;
case IconAlignment.BottomLeft:
x = -0.5 * boxWidth + xOffset;
y = 0.5 * boxHeight + yOffset;
break;
case IconAlignment.Bottom:
x = xOffset;
y = 0.5 * boxHeight + yOffset;
break;
case IconAlignment.BottomRight:
x = 0.5 * boxWidth + xOffset;
y = 0.5 * boxHeight + yOffset;
break;
default:
return; // Should not hit, effectively center
}
// Apply offset
x += this._iconOffset.x;
y += this._iconOffset.y;
// Apply marker position
const markerPos = this.position;
x += markerPos.x;
y += markerPos.y;
/// Note: only call this *after* setting position = absolute
const size = this._iconHtmlElement.getBoundingClientRect();
this._iconHtmlElement.style.left = `${x - (size.width / 2)}px`;
this._iconHtmlElement.style.top = `${y - (size.height / 2)}px`;
}
/**
* Assigns a handler to the onMouseButton event.
* NOTE: called two times per click event (down/up). Handle appropriately.
*/
setMouseButtonHandler(handler) {
this._onMouseButtonHandler = handler;
}
/** Assigns a handler to the onMouseEnter event. */
setMouseEnterHandler(handler) {
this._onMouseEnterHandler = handler;
}
/** Assigns a handler to the onMouseLeave event. */
setMouseLeaveHandler(handler) {
this._onMouseLeaveHandler = handler;
}
setPosition(vp) {
if (Visibility.Hidden === this.visibility)
return false;
this._viewPosition = vp.worldToView(this.worldLocation, this._viewPosition);
if (!vp.viewRect.containsPoint(this._viewPosition))
return false;
this._currPixelSize = vp.getPixelSizeAtPoint();
if (this.isCollapsed())
return false;
this._textAngle.setRadians(this.calculateAngle(vp));
return true;
}
isCollapsed() {
if (Visibility.CollapseOversized !== this.visibility)
return false;
// Don't collapse if we never had a draw call.
const boxMax = Math.max(this._boxSize.x, this._boxSize.y);
if (boxMax <= 0)
return false;
const worldWidth = boxMax * this._currPixelSize;
if (worldWidth < this.maxWorldWidth)
return false;
return true;
}
calculateAngle(vp) {
if (undefined === this.textDirection)
return 0.0;
const x = vp.view.getXVector().dotProduct(this.textDirection);
const y = vp.view.getYVector().dotProduct(this.textDirection);
return Math.atan(y / x);
}
/** Adds this decoration to the supplied DecorateContext. */
addDecoration(context) {
if (this.setPosition(context.viewport)) {
this._currViewport = context.viewport;
context.addCanvasDecoration(this);
// Add icon if any, and position it relative to the canvas decoration
if (this._iconHtmlElement !== undefined) {
context.addHtmlDecoration(this._iconHtmlElement);
this.positionIconHtml();
}
}
}
/** Returns (a reference to) the current position of the decoration in view coordinates (pixels).
* It's the result of worldToView of the wordLocation and does not account for offset.
*/
get position() { return this._viewPosition; }
/** Returns the central position of the TextMarker in view coordinates, including computed offset. */
get computedPosition() {
const coords = Point2d.createFrom(this._viewPosition);
coords.x += this.getXOffsetFromCenterInPixels(this._boxSize.x);
coords.y += this.getYOffsetFromCenterInPixels(this._boxSize.y);
return coords;
}
/** Sets the `textFillStyle` attribute based on a ColorDef's RGBA. */
set textColor(v) {
this._textFillStyle = v.toHexString();
this._textFillAlpha = v.getAlpha() / 255.0;
}
/** Sets the `backgroundFillStyle` attribute based on a ColorDef's RGBA. */
set boxColor(v) {
this._boxFillColor = undefined === v ? undefined : v;
this._boxFillStyle = undefined === v ? undefined : v.toHexString();
this._boxFillAlpha = undefined === v ? 1.0 : v.getAlpha() / 255.0;
}
isStringArray(value) {
if (0 === value.length)
return true;
return (typeof value[0] === "string" || value[0] instanceof String);
}
/** Creates the closed polygon for picking. */
updateBoxOutline(boxWidth, boxHeight) {
this._boxOutline = undefined;
if (!this.pickable)
return;
const xOffset = this.getXOffsetFromCenterInPixels(boxWidth);
const yOffset = this.getYOffsetFromCenterInPixels(boxHeight);
if (this.hiliteProps && this.hiliteProps.isHilited && undefined !== this.hiliteProps.scaleFactor) {
boxWidth = this.hiliteProps.scaleFactor * boxWidth;
boxHeight = this.hiliteProps.scaleFactor * boxHeight;
}
const topLeftCorner = Point3d.create(-0.5 * boxWidth + xOffset, -0.5 * boxHeight + yOffset, 0.0);
const outline = LineString3d.createRectangleXY(topLeftCorner, boxWidth, boxHeight, true);
if (!this._textAngle.isAlmostZero) {
const rotation = Transform.createRefs(Point3d.createZero(), Matrix3d.createRotationAroundAxisIndex(AxisIndex.Z, this._textAngle.cloneScaled(-1.0)));
if (!outline.tryTransformInPlace(rotation))
return;
}
if (!outline.tryTranslateInPlace(this._viewPosition.x, this._viewPosition.y))
return;
this._boxOutline = outline.points;
}
drawDecoration(ctx) {
if (0 === this.textLines.length)
return;
ctx.font = this.textFont;
ctx.textBaseline = "top";
// Make sure to get measurements before any scaling is applied
const boxWidth = this.getBoxWidth(ctx);
const boxHeight = this.getBoxHeight(ctx);
const lineHeight = this.getLineHeight(ctx);
this._boxSize.set(boxWidth, boxHeight);
if (this.isCollapsed())
return;
// Rotation angle must be CW and in radians
if (!this._textAngle.isAlmostZero)
ctx.rotate(-this._textAngle.radians);
// Translate the canvas origin so that (0,0) is at the center of the text
const dx = -0.5 * boxWidth;
const dy = -0.5 * boxHeight;
ctx.translate(this.getXOffsetFromCenterInPixels(boxWidth), this.getYOffsetFromCenterInPixels(boxHeight));
if (this.hiliteProps && this.hiliteProps.isHilited) {
if (this.hiliteProps.color) {
ctx.shadowBlur = 30;
ctx.shadowColor = this.hiliteProps.color;
}
if (this.hiliteProps.scaleFactor)
ctx.scale(this.hiliteProps.scaleFactor, this.hiliteProps.scaleFactor);
}
const radius = Math.max(0, this.boxCornerRadius);
if (undefined !== this._boxFillStyle) {
const bbw = Math.max(0, this.boxBorderWidth);
ctx.globalAlpha = this._boxFillAlpha;
if (this.pickable && this.transientHiliteId && this._currViewport) {
let fillColorStyle = this._boxFillStyle;
let shadowColorStyle = this._boxFillStyle;
let shadowBlur = 0;
// If ID is being flashed, draw with the original fill color lerped with the hilite color based on current flash intensity
if (this._currViewport.lastFlashedElementId === this.transientHiliteId) {
const startColor = this._boxFillColor ?? this._currViewport.hilite.color;
fillColorStyle = startColor.lerp(this._currViewport.hilite.color, this._currViewport.flashSettings.maxIntensity).toHexString();
shadowColorStyle = fillColorStyle;
shadowBlur = (this._currViewport.hilite.silhouette === Hilite.Silhouette.Thick) ? 2 : 1;
// If ID is selected, draw with hilite color silhoutte and blended fill hilite based on visible ratio.
}
else if (this._currViewport.iModel.selectionSet.has(this.transientHiliteId)) {
const startColor = this._boxFillColor ?? this._currViewport.hilite.color;
fillColorStyle = startColor.lerp(this._currViewport.hilite.color, this._currViewport.hilite.visibleRatio).toHexString();
shadowColorStyle = this._currViewport.hilite.color.toHexString();
shadowBlur = (this._currViewport.hilite.silhouette === Hilite.Silhouette.Thick) ? 2 : 1;
}
ctx.shadowBlur = shadowBlur;
ctx.shadowColor = shadowColorStyle;
ctx.fillStyle = fillColorStyle;
}
else {
ctx.fillStyle = this._boxFillStyle;
}
this.drawRect(ctx, dx - bbw, dy - bbw, boxWidth + 2 * bbw, boxHeight + 2 * bbw, radius);
ctx.fill();
if (0 < bbw) {
ctx.lineWidth = bbw;
ctx.strokeStyle = (this._boxBorderColor === undefined) ? ColorDef.black.toHexString() : this._boxBorderColor;
ctx.stroke();
}
}
ctx.globalAlpha = this._textFillAlpha;
ctx.fillStyle = this._textFillStyle;
if (this.isStringArray(this.textLines)) {
this.textLines.forEach((text, index) => {
ctx.fillText(text, dx + this.boxPadding, dy + this.boxPadding + (index * lineHeight));
});
}
else {
const boldFont = `bold ${this.textFont}`;
this.textLines.forEach((entry, index) => {
ctx.font = boldFont;
const w = ctx.measureText(entry.label).width + TextMarker._titleTextSpacing;
ctx.fillText(entry.label, dx + this.boxPadding, dy + this.boxPadding + (index * lineHeight));
ctx.font = this.textFont;
ctx.fillText(entry.value, dx + this.boxPadding + w, dy + this.boxPadding + (index * lineHeight));
});
}
this.updateBoxOutline(boxWidth, boxHeight);
this.moveIconHtml(boxWidth, boxHeight);
}
drawRect(ctx, x, y, w, h, r) {
if (w < 2 * r)
r = w / 2;
if (h < 2 * r)
r = h / 2;
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
onMouseEnter(ev) {
if (this._onMouseEnterHandler)
this._onMouseEnterHandler(ev);
if (this.transientHiliteId && ev.viewport) {
ev.viewport.flashedId = this.transientHiliteId;
this._flashViewports.add(ev.viewport);
}
}
onMouseLeave() {
if (this._onMouseLeaveHandler)
this._onMouseLeaveHandler();
this._flashViewports.forEach((vp) => vp.flashedId = undefined);
this._flashViewports.clear();
}
onMouseButton(ev) {
// If has a transient ID, we want to participate in selection events
this.handleTransientSelection(ev);
if (!this._onMouseButtonHandler)
return false;
return this._onMouseButtonHandler(ev);
}
pick(pt) {
if (!this.pickable || undefined === this._boxOutline)
return false;
return -1 !== PolygonOps.classifyPointInPolygon(pt.x, pt.y, this._boxOutline);
}
handleTransientSelection(ev) {
if (!this.pickable || !this.transientHiliteId || !ev.viewport)
return;
const selectionMode = this.getSelectionModeIfSelectToolIsActive();
if (selectionMode !== undefined && !ev.isDown) {
const selectionSet = ev.viewport.iModel.selectionSet;
switch (selectionMode) {
case SelectionMode.Replace:
if (ev.isControlKey) {
if (ev.button === BeButton.Data)
selectionSet.invert(this.transientHiliteId);
else if (ev.button === BeButton.Reset && selectionSet.has(this.transientHiliteId))
selectionSet.remove(this.transientHiliteId);
}
else if (ev.button === BeButton.Data) {
selectionSet.replace(this.transientHiliteId);
// Noticed if we selected a single element then reset on it, it gets removed from the SS. Multiple elements do not unless if using ctrl
}
else if (ev.button === BeButton.Reset && selectionSet.size === 1 && selectionSet.has(this.transientHiliteId)) {
selectionSet.remove(this.transientHiliteId);
}
break;
case SelectionMode.Add:
if (ev.button === BeButton.Data)
selectionSet.add(this.transientHiliteId);
break;
case SelectionMode.Remove:
if (ev.button === BeButton.Data)
selectionSet.remove(this.transientHiliteId);
break;
}
}
}
getLineHeight(ctx) {
return this.textLineHeight * ctx.measureText("M").width;
}
getBoxWidth(ctx) {
let widths = [];
if (this.isStringArray(this.textLines)) {
widths = this.textLines.map((v) => ctx.measureText(v).width);
}
else {
const boldFont = `bold ${this.textFont}`;
widths = this.textLines.map((entry) => {
ctx.font = boldFont;
const titleWidth = ctx.measureText(entry.label).width;
ctx.font = this.textFont;
const textWidth = ctx.measureText(entry.value).width;
return titleWidth + textWidth + TextMarker._titleTextSpacing;
});
ctx.font = this.textFont;
}
const maxWidth = widths.reduce((max, v) => Math.max(max, v));
return maxWidth + 2 * this.boxPadding;
}
getBoxHeight(ctx) {
const lineHeight = this.getLineHeight(ctx);
// Cut off the remaining space after the last line
const overflow = (this.textLineHeight - 1.0) * (lineHeight / this.textLineHeight);
return lineHeight * this.textLines.length + 2 * this.boxPadding - overflow;
}
getXOffsetFromCenterInPixels(paddedBoxWidth) {
if (undefined === this.offset)
return 0.0;
switch (this.offset.type) {
case TextOffsetType.Percentage: return this.offset.x * paddedBoxWidth;
case TextOffsetType.Pixels: return this.offset.x;
default: return 0.0;
}
}
getYOffsetFromCenterInPixels(paddedBoxHeight) {
if (undefined === this.offset)
return 0.0;
switch (this.offset.type) {
case TextOffsetType.Percentage: return this.offset.y * paddedBoxHeight;
case TextOffsetType.Pixels: return this.offset.y;
default: return 0.0;
}
}
getSelectionModeIfSelectToolIsActive() {
const tool = IModelApp.toolAdmin.currentTool;
if (tool instanceof SelectionTool)
return tool.selectionMode;
return undefined;
}
} // TextMarker
/** Expose the visibility enum. */
TextMarker.Visibility = Visibility;
TextMarker._titleTextSpacing = 5;
//# sourceMappingURL=TextMarker.js.map