Markers

markerset sample

A Marker is a visual indicator whose position in a view follows a fixed location in world space. The name and the concept derive from the same type in the Google Maps api. Markers may also provide actions performed when the mouse moves over them, and when the are clicked.

Markers can display 3 optional parts:

  • World decorations that are drawn into the scene with the z-buffer enabled.
  • Canvas decorations that are drawn on top scene using CanvasRenderingContext2D calls
  • HTML decorations that are HTMLElements on top of the view

Markers are often used to show locations in physical space where records from an external data source are located. They provide a way for applications to show additional information from the external source as the cursor hovers over them, and actions to be performed when they are clicked.

Sometimes Markers are used to show the location of elements within an iModel that are of interest. In that case the location of the Marker can be established from the origin, center, or perhaps other points derived from the element's properties.

MarkerSets

Often there will be many Markers relevant to show a group of points of interest. When the set of Markers becomes large, or when the user zooms far away from Marker locations, they tend to overlap one another and create clutter. For this purpose, the class MarkerSet provides a way to group sets of related Markers together such that overlapping groups of them form a Cluster. MarkerSet provides techniques for you to supply the graphics to visually indicate the set of Markers it represents.

Note: Only Markers from the same MarkerSet will be clustered. Independent Markers or Markers from different MarkerSets will not cluster.

The following example illustrates creating a marker set using random locations within the ProjectExtents:

/** Example Marker to show an *incident*. Each incident has an *id*, a *severity*, and an *icon*. */
class IncidentMarker extends Marker {
  private static _size = Point2d.create(30, 30);
  private static _imageSize = Point2d.create(40, 40);
  private static _imageOffset = Point2d.create(0, 30);
  private static _amber = ColorDef.create(ColorByName.amber);
  private static _sweep360 = AngleSweep.create360();
  private _color: ColorDef;

  // uncomment the next line to make the icon only show when the cursor is over an incident marker.
  // public get wantImage() { return this._isHilited; }

  /** Get a color based on severity by interpolating Green(0) -> Amber(15) -> Red(30)  */
  public static makeColor(severity: number): ColorDef {
    return (severity <= 16 ? ColorDef.green.lerp(this._amber, (severity - 1) / 15.) :
      this._amber.lerp(ColorDef.red, (severity - 16) / 14.));
  }

  public override onMouseButton(ev: BeButtonEvent): boolean {
    if (ev.button === BeButton.Data) {
      if (ev.isDown) {
        IModelApp.notifications.openMessageBox(MessageBoxType.LargeOk, `severity = ${this.severity}`, MessageBoxIconType.Information); // eslint-disable-line @typescript-eslint/no-floating-promises
      }
    }
    return true;
  }

  /** Create a new IncidentMarker */
  constructor(location: XYAndZ, public severity: number, public id: number, icon: HTMLImageElement) {
    super(location, IncidentMarker._size);
    this._color = IncidentMarker.makeColor(severity); // color interpolated from severity
    this.setImage(icon); // save icon
    this.imageOffset = IncidentMarker._imageOffset; // move icon up by 30 pixels
    this.imageSize = IncidentMarker._imageSize; // 40x40
    this.title = `Severity: ${severity}<br>Id: ${id}`; // tooltip
    this.setScaleFactor({ low: .2, high: 1.4 }); // make size 20% at back of frustum and 140% at front of frustum (if camera is on)

    // it would be better to use "this.label" here for a pure text string. We'll do it this way just to show that you can use HTML too
    this.htmlElement = document.createElement("div");
    this.htmlElement.innerHTML = id.toString(); // just put the id of the incident as text
  }

  public override addMarker(context: DecorateContext) {
    super.addMarker(context);
    const builder = context.createGraphicBuilder(GraphicType.WorldDecoration);
    const ellipse = Arc3d.createScaledXYColumns(this.worldLocation, context.viewport.rotation.transpose(), .2, .2, IncidentMarker._sweep360);
    builder.setSymbology(ColorDef.white, this._color, 1);
    builder.addArc(ellipse, false, false);
    builder.setBlankingFill(this._color);
    builder.addArc(ellipse, true, true);
    context.addDecorationFromBuilder(builder);
  }
}

/** A Marker used to show a cluster of incidents */
class IncidentClusterMarker extends Marker {
  private _clusterColor: string;
  // public get wantImage() { return this._isHilited; }

  // draw the cluster as a white circle with an outline color based on what's in the cluster
  public override drawFunc(ctx: CanvasRenderingContext2D) {
    ctx.beginPath();
    ctx.strokeStyle = this._clusterColor;
    ctx.fillStyle = "white";
    ctx.lineWidth = 5;
    ctx.arc(0, 0, 13, 0, Math.PI * 2);
    ctx.fill();
    ctx.stroke();
  }

  /** Create a new cluster marker with label and color based on the content of the cluster */
  constructor(location: XYAndZ, size: XAndY, cluster: Cluster<IncidentMarker>, image: Promise<MarkerImage> | MarkerImage | undefined) {
    super(location, size);

    // get the top 10 incidents by severity
    const sorted: IncidentMarker[] = [];
    const maxLen = 10;
    cluster.markers.forEach((marker) => {
      if (maxLen > sorted.length || marker.severity > sorted[sorted.length - 1].severity) {
        const index = sorted.findIndex((val) => val.severity < marker.severity);
        if (index === -1)
          sorted.push(marker);
        else
          sorted.splice(index, 0, marker);
        if (sorted.length > maxLen)
          sorted.length = maxLen;
      }
    });

    this.imageOffset = new Point3d(0, 28);
    this.imageSize = new Point2d(30, 30);
    this.label = cluster.markers.length.toLocaleString();
    this.labelColor = "black";
    this.labelFont = "bold 14px sans-serif";

    let title = "";
    sorted.forEach((marker) => {
      if (title !== "")
        title += "<br>";
      title += `Severity: ${marker.severity} Id: ${marker.id}`;
    });
    if (cluster.markers.length > maxLen)
      title += "<br>...";

    this.title = title;
    this._clusterColor = IncidentMarker.makeColor(sorted[0].severity).toHexString();
    if (image)
      this.setImage(image);
  }
}

/** A MarkerSet to hold incidents. This class supplies to `getClusterMarker` method to create IncidentClusterMarkers. */
class IncidentMarkerSet extends MarkerSet<IncidentMarker> {
  protected getClusterMarker(cluster: Cluster<IncidentMarker>): Marker {
    return new IncidentClusterMarker(cluster.getClusterLocation(), cluster.markers[0].size, cluster, IncidentMarkerDemo.decorator!.warningSign);
  }
}

/** This demo shows how to use MarkerSets to cluster markers that overlap on the screen. It creates a set of 500
 * "incidents" at random locations within the ProjectExtents. For each incident, it creates an IncidentMarker with an Id and
 * with a random value between 1-30 for "severity", and one of 5 possible icons.
 */
export class IncidentMarkerDemo {
  private _awaiting = false;
  private _loading?: Promise<any>;
  private _images: Array<HTMLImageElement | undefined> = [];
  private _incidents = new IncidentMarkerSet();
  private static _numMarkers = 500;
  public static decorator?: IncidentMarkerDemo; // static variable so we can tell if the demo is active.

  public get warningSign() { return this._images[0]; }

  // Load one image, logging if there was an error
  private async loadOne(src: string) {
    try {
      return await imageElementFromUrl(src); // note: "return await" is necessary inside try/catch
    } catch {
      const msg = `Could not load image ${src}`;
      Logger.logError("IncidentDemo", msg);
      console.log(msg); // eslint-disable-line no-console
    }
    return undefined;
  }

  // load all images. After they're loaded, make the incident markers
  private async loadAll(extents: AxisAlignedBox3d) {
    const loads = [
      this.loadOne("Warning_sign.svg"), // must be first, see "get warningSign()" above
      this.loadOne("Hazard_biological.svg"),
      this.loadOne("Hazard_electric.svg"),
      this.loadOne("Hazard_flammable.svg"),
      this.loadOne("Hazard_toxic.svg"),
      this.loadOne("Hazard_tripping.svg"),
    ];
    await (this._loading = Promise.all(loads)); // this is a member so we can tell if we're still loading
    for (const img of loads)
      this._images.push(await img);

    const len = this._images.length;
    const pos = new Point3d();
    for (let i = 0; i < IncidentMarkerDemo._numMarkers; ++i) {
      pos.x = extents.low.x + (Math.random() * extents.xLength());
      pos.y = extents.low.y + (Math.random() * extents.yLength());
      pos.z = extents.low.z + (Math.random() * extents.zLength());
      const img = this._images[(i % len) + 1];
      if (undefined !== img)
        this._incidents.markers.add(new IncidentMarker(pos, 1 + Math.round(Math.random() * 29), i, img));
    }
    this._loading = undefined;
  }

  public constructor(extents: AxisAlignedBox3d) {
    this.loadAll(extents); // eslint-disable-line @typescript-eslint/no-floating-promises
  }

  /** We added this class as a ViewManager.decorator below. This method is called to ask for our decorations. We add the MarkerSet. */
  public decorate(context: DecorateContext) {
    if (!context.viewport.view.isSpatialView())
      return;

    if (undefined === this._loading) {
      this._incidents.addDecoration(context);
      return;
    }

    // if we're still loading, just mark this viewport as needing decorations when all loads are complete
    if (!this._awaiting) {
      this._awaiting = true;
      this._loading.then(() => {
        context.viewport.invalidateDecorations();
        this._awaiting = false;
      }).catch(() => undefined);
    }
  }

  /** Turn the markers on and off. Each time it runs it creates a new random set of incidents. */
  public static toggle(extents: AxisAlignedBox3d) {
    if (undefined === IncidentMarkerDemo.decorator) {
      // start the demo by creating the IncidentMarkerDemo object and adding it as a ViewManager decorator.
      IncidentMarkerDemo.decorator = new IncidentMarkerDemo(extents);
      IModelApp.viewManager.addDecorator(IncidentMarkerDemo.decorator);
    } else {
      // stop the demo
      IModelApp.viewManager.dropDecorator(IncidentMarkerDemo.decorator);
      IncidentMarkerDemo.decorator = undefined;
    }
  }
}

Last Updated: 14 November, 2024